diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index a1b09e6..036670a 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2124,6 +2124,15 @@ canonical_value! { // Score settings (M2d) — value-typed set-* payloads embed these. ScoreMetadata, MetricGrid, + // Phase-3 ops tranche — value-typed create/set payloads embed these. + // `TimeSignature::dec` re-validates the beat-group sum through + // `TimeSignature::new` (the "reject at construction *and* at decode" + // discipline the operation catalog's §"Meter and Tempo Overwrites" + // requires), so a malformed value never round-trips. + Staff, + TimeSignature, + TempoSegment, + StaffLineConfiguration, } #[cfg(test)] diff --git a/crates/epiphany-editor-core/src/barriers.rs b/crates/epiphany-editor-core/src/barriers.rs index 30328d5..673725d 100644 --- a/crates/epiphany-editor-core/src/barriers.rs +++ b/crates/epiphany-editor-core/src/barriers.rs @@ -378,6 +378,35 @@ pub(crate) fn subjects_of(kind: &OperationKind, score: &Score) -> BarrierSubject ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)), ) } + // Phase-3 first tranche. A staff is a global (score-root) object with + // no regional containment; a meter overwrite edits its region's grid + // slot and names the carried signature it mints; a tempo overwrite + // names its region scope (the score-level map is score-wide state); a + // layout overwrite names its staff instance. + OperationKind::CreateStaff(op) => { + one(TypedObjectId::Staff(op.staff_id()), EditContext::default()) + } + OperationKind::SetTimeSignature(op) => { + let mut objects = vec![(TypedObjectId::Region(op.region), ctx(Some(op.region), None))]; + if let Some(signature) = &op.time_signature { + objects.push(( + TypedObjectId::TimeSignature(signature.id), + ctx(Some(op.region), None), + )); + } + BarrierSubjects::Objects(objects) + } + OperationKind::SetTempoSegment(op) => match op.region { + Some(region) => one(TypedObjectId::Region(region), ctx(Some(region), None)), + None => BarrierSubjects::ScoreWide, + }, + OperationKind::SetStaffLayout(op) => one( + TypedObjectId::StaffInstance(op.staff_instance), + ctx( + region_of_staff_instance(score, op.staff_instance), + Some(op.staff_instance), + ), + ), OperationKind::SetMetadata(_) | OperationKind::DeclareTransaction(_) => { BarrierSubjects::ScoreWide } diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 25c92de..db320df 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -5233,6 +5233,90 @@ mod tests { .expect("an Event target does not match a Pitch-kind barrier"); } + #[test] + fn phase3_operation_kinds_derive_subjects_and_gate_on_barriers() { + // The Phase-3 tranche (CreateStaff / SetTimeSignature / SetTempoSegment + // / SetStaffLayout) participates in the barrier gate: a whole-score + // barrier naming each tag refuses the matching edit before minting. + let mut session = open_plain(7); + let region = session.score().canvas.regions[0].id; + let instance = session.score().canvas.regions[0].staff_instances()[0].id; + let staff = session.score().staves[0].id; + let instrument = session.score().staves[0].instrument; + let anchor = epiphany_core::TimeAnchor::Region { + id: region, + edge: epiphany_core::RegionEdge::Start, + offset: epiphany_core::AnchorOffset::Zero, + }; + let edits: Vec<(OperationKindTag, OperationKind)> = vec![ + ( + OperationKindTag::InsertStaff, + OperationKind::CreateStaff(epiphany_ops::CreateStaffOp { + staff: epiphany_ops::valuegen::staff(staff, instrument), + }), + ), + ( + OperationKindTag::SetTimeSignature, + OperationKind::SetTimeSignature(epiphany_ops::SetTimeSignatureOp { + region, + anchor: anchor.clone(), + time_signature: None, + }), + ), + ( + OperationKindTag::SetTempoSegment, + OperationKind::SetTempoSegment(epiphany_ops::SetTempoSegmentOp { + region: Some(region), + start: anchor, + segment: None, + }), + ), + ( + OperationKindTag::SetStaffLayout, + OperationKind::SetStaffLayout(epiphany_ops::SetStaffLayoutOp { + staff_instance: instance, + instrument_override: None, + staff_lines_override: None, + visible: false, + }), + ), + ]; + for (tag, kind) in &edits { + session.set_active_extensions(vec![extension_prohibiting(0xE7, *tag)]); + assert_eq!( + session.apply(kind.clone()), + Err(EditorError::BarrierProhibited { + extension: ExtensionRef(0xE7), + operation: *tag, + }), + "a whole-score barrier naming {tag:?} must refuse the edit" + ); + } + assert!(session.applied_operations().is_empty()); + + // With no barriers active, the layout advisory applies end to end (the + // spec declares no advisory preconditions for the tranche). + session.set_active_extensions(vec![]); + session + .apply(OperationKind::SetStaffLayout( + epiphany_ops::SetStaffLayoutOp { + staff_instance: instance, + instrument_override: None, + staff_lines_override: None, + visible: false, + }, + )) + .expect("an unbarred layout write applies"); + let materialized = session + .score() + .staff_instances() + .find(|(_, si)| si.id == instance) + .expect("instance survives") + .1 + .clone(); + assert!(!materialized.visible); + } + #[test] fn an_unsafe_edit_crosses_the_barrier_and_records_the_tombstone_obligation() { let mut session = open_plain(7); diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index c6a79ac..cb3d4b1 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -123,6 +123,10 @@ fn payload_label(payload: &OperationPayload) -> &'static str { OperationKind::InsertEvent(_) => "InsertEvent", OperationKind::RespellPitch(_) => "RespellPitch", OperationKind::DeclareTransaction(_) => "DeclareTransaction", + OperationKind::CreateStaff(_) => "CreateStaff", + OperationKind::SetTimeSignature(_) => "SetTimeSignature", + OperationKind::SetTempoSegment(_) => "SetTempoSegment", + OperationKind::SetStaffLayout(_) => "SetStaffLayout", _ => "primitive", }, OperationPayload::ResolveConflict(_) => "ResolveConflict", @@ -176,7 +180,7 @@ struct EditorApp { impl EditorApp { fn new() -> Self { let score = epiphany_testkit::fixtures::ten_measure_single_staff(0); - let session = EditorSession::open(score, Box::new(Engraver)) + let session = EditorSession::open(score, Box::new(Engraver::default())) .expect("the ten-measure fixture renders under the real engraver"); EditorApp { session, diff --git a/crates/epiphany-engrave/Cargo.toml b/crates/epiphany-engrave/Cargo.toml index dda1df1..50c1981 100644 --- a/crates/epiphany-engrave/Cargo.toml +++ b/crates/epiphany-engrave/Cargo.toml @@ -5,18 +5,18 @@ edition.workspace = true rust-version.workspace = true authors.workspace = true repository.workspace = true -description = "Agent I's Epiphany engraving solver (spec Chapter 9): turns a ConstrainedLayoutIR into a ResolvedLayoutIR with real geometry. A deterministic horizontal-spacing pass (the first axis of the planned two-pass spring layout) that evaluates the declared hard constraints and reports SolverTier::Minimal. The vertical spring pass and casting-off are deferred to a later tier." +description = "Agent I's Epiphany engraving solver (spec Chapter 9): turns a ConstrainedLayoutIR into a ResolvedLayoutIR with real geometry. A deterministic horizontal-spacing pass plus Minimal-tier casting-off (greedy system breaking at measure boundaries, vertical system stacking, page assignment) that evaluates the declared hard constraints — break constraints included — and reports SolverTier::Minimal." [dependencies] # The solver consumes/produces the Chapter 7 IR stages and implements the # Chapter 9 `ConstraintSolver` interface — all defined in epiphany-layout-ir, -# which re-exports the epiphany-core ids/types the solver references. +# which re-exports most of the epiphany-core ids/types the solver references. epiphany-layout-ir.workspace = true +# The casting-off pass matches provenance sources by their typed id (staves for +# per-system staff records, measures for per-system measure records). +epiphany-core.workspace = true [dev-dependencies] -# Tests drive the solver from real score fixtures via epiphany-core's generators -# (and a couple of leaf id/time types). -epiphany-core.workspace = true # The criterion-6 round-trip test mirrors the hand-off gate's fixtures, including # epiphany-testkit's `ten_measure_single_staff` (testkit does not depend on this # crate, so this dev-dep introduces no cycle). diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index c7cb974..e48e4f3 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -15,26 +15,30 @@ on the product side of the spec's core/product boundary, so replacing the `StubSolver` *inside* `layout-ir` would blur it (`spec/PHASE2_QUICKSTART.md`, crate topology). -**This phase ships the renderer-against-stub slice** (QUICKSTART, Agent I, +**Phase 2 shipped the renderer-against-stub slice** (QUICKSTART, Agent I, "Development pattern": build the renderer against the stub solver first, then -grow the real solver). So this crate is an **honest scaffold**: `Engraver` runs a -genuine deterministic *horizontal spacing pass* — the first axis of the planned -two-pass spring layout — placing each spring slot left-to-right by its preferred -width, rather than echoing the stub's input columns. It does **not yet** run the -vertical pass, the soft-spring stretch/compress solve, or evaluate the IR's -declared hard constraints. +grow the real solver): a genuine deterministic *horizontal spacing pass* — the +first axis of the planned two-pass spring layout — later joined by real +hard-constraint evaluation (which earned the `Minimal` tier). + +**Phase 3's layout track adds CASTING-OFF** (see "Casting-off (2026-07)" below): +greedy system breaking at measure boundaries, vertical system stacking, page +assignment, a populated `ResolvedPage`/`ResolvedSystem` tree, and full break- +constraint evaluation. Still deferred: the vertical soft-spring solve within a +system, per-system justification/stretch, and optimal break search. ### Honest tier By the same rule `layout-ir`'s `StubSolver` follows, a solver that does not evaluate the declared hard constraints and computes no quality metrics MUST report `SolverTier::Stub`, never `Minimal` (Chapter 9 §"Conformance Tiers"). -`Engraver::tier()` therefore reports `Stub` today; it is promoted to `Minimal` in -the same change that lands real hard-constraint satisfaction. A regression test -(`reports_the_honest_stub_tier_until_it_earns_minimal`) guards this so the tier -cannot be silently inflated. The quality-metric vector stays the conservative +`Engraver::tier()` reported `Stub` until real hard-constraint satisfaction +landed and now reports `Minimal` — which it fully earns after casting-off: the +break constraint family is genuinely supported (spec §"Conformance Tiers", +Minimal row), and `Minimal` makes no optimality claim, so greedy first-fit +casting-off is legitimate. The quality-metric vector stays the conservative all-worst placeholder (`QualityMetricVector::unmeasured`) until the Quality Metric -Catalog lands (Phase 3, explicitly out of Agent I's scope). +Catalog lands (`Standard` tier work). ## Implementation decisions (QUICKSTART "Decisions you'll need to make") @@ -69,6 +73,106 @@ Catalog lands (Phase 3, explicitly out of Agent I's scope). layer; it changes only `position`.** It assigns each glyph the `x` of its spring slot and keeps its baseline `y` (the vertical pass is future work). +## Casting-off (2026-07) — decisions + +1. **Greedy first-fit system breaking, at measure boundaries.** The casting-off + walk visits each region's spaced spring-slot columns in x order and breaks + before a **barline column** (this projection draws each measure's barline at + its *start* column, so breaking before the barline keeps every measure + intact; the region-final barline closes the region and is never a + candidate) whenever the measure beginning there would overflow the page + content width. Rationale: `SolverTier::Minimal` requires the break family + supported and hard constraints satisfied, with **no optimality claim** + (Chapter 9 §"Conformance Tiers"), so an optimal (Knuth–Plass-style) search + is deliberately rejected at this tier — greedy first-fit is deterministic, + linear, and easy to validate. Consequences accepted and documented: + a region with no measures never wraps automatically; a single measure wider + than the page yields an overfull system (no mid-measure emergency break). +2. **Break-constraint semantics: "breaks at slot S" ⇔ S starts a system.** A + `SystemBreakAt`/`PageBreakAt` is satisfied iff the final layout starts a + system/page at that slot (a region's first slot is trivially at a + boundary). Hard breaks are always honoured (mid-measure if necessary — a + `Required` constraint binds absolutely); soft breaks are honoured unless + the closing system would carry **no musical content** (no notehead/rest + column) — the pathological path: the break is skipped, the soft violation + warned, and the unhonoured preference recorded as an + `EngravingDecision` with `DecisionSource::IrOverride` (spec's + override-resolution rule: record, never silently drop). +3. **Frame of constraint evaluation.** Geometric constraints (no-collision, + alignment, position-within) are evaluated against the **pre-casting spaced + geometry** — the frame they are expressed in. Casting-off then relocates + whole systems by per-system rigid motions, which cannot un-satisfy an + intra-system geometric obligation; evaluating post-casting would instead + make *every* `PositionWithin` (whose rect pins the region's source-frame + vertical envelope) unsatisfiable for any casting-off solver, which cannot + be the spec's intent. Break constraints are evaluated against the **final + break structure**. +4. **Page geometry is an engraver parameter (`PageGeometry`), defaulted to A4 + at an 8 mm staff.** The spec names `Canvas.layout_defaults` ("paper size, + margins") but never defines the type, and core does not implement it; + adding a graph field now would violate the companion's frozen-layout rule, + so the graph home (`CanvasLayoutDefaults`) is **staged to the data-model + schema major** and the engraver takes the geometry as a constructor + parameter. Default arithmetic (1 staff space = staff height / 4 = 2.0 mm at + an 8 mm staff): A4 210 × 297 mm → **105 × 148.5** staff spaces; 15 mm + margins → **7.5** staff spaces; content area 180 × 267 mm → **90 × 133.5** + staff spaces. 90 staff spaces wraps the ten-measure hand-off fixture + (≈ 99 staff spaces spaced) into two systems — an honest multi-system + default golden. +5. **World-frame convention: pages stacked vertically in one world.** Page 1's + top-left corner sits at the origin; page *n*'s frame begins a full page + height plus `INTER_PAGE_GAP` (8 staff spaces, a presentation constant) + below page *n − 1*'s. Every glyph/stroke position is **baked** into this + single y-up world frame (per-system rigid translation: x back to the left + margin, y to the stacked position), so the SVG renderer and the hit-test + map work unchanged on the flat lists — no per-page transform exists + anywhere downstream. The inter-*system* gap is read from + `VerticalBand::inter_system_gap` (preferred 4.0 staff spaces), so the + casting-off gap and the band model cannot drift. +6. **System-spanning strokes are split; the split is provenance-honest.** The + five staff lines span the whole region; a break cuts them at the systems' + content edges. The first segment keeps the original stroke's exact + provenance (round-trip preservation); each later segment is synthesized + (`SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS)`, the codebase's + convention for kinds the normative vocabulary does not name) with a + `continuation_instance_key(original stable id, ordinal)` instance key. The + round-trip contract in `layout-ir` was relaxed accordingly (containment + + declared-synthesis additions; the stub still must add nothing). +7. **Engraved-break decisions.** Every chosen system/page break appends an + `EngravingDecision` whose **target** is the `MUSCLOID` id synthesized from + the owning region's source under `SynthesisKind::EngravedBreak`, keyed by + the breaking slot's (content-derived) identity. Source attribution: + `UserOverride(id)` when the break constraint was projected from a user + break override (the id flows through the new + `ConstrainedLayoutIR.break_origins`), else `Automatic`; a skipped soft + break records `IrOverride`. A boundary that actually opens a page records + `PageBreak`; a later page opening at a region's own first system records + `PageBreak` too; other boundaries record `SystemBreak`. +8. **Inverted tests.** Two tests that pinned the single-system semantics were + deliberately inverted and renamed: + `a_hard_break_cannot_be_honoured_by_single_system_minimal` → + `a_hard_break_is_honoured_by_casting_off` (Unsatisfiable → Solved with the + system count increasing), and + `a_users_break_flows_to_a_soft_violation_not_a_failure` → + `a_users_break_is_honoured_and_recorded_with_its_override` (soft-violation + warning → clean Solved, break at the anchor's column, decision recorded + with the user's override id). The pathological-soft path keeps the old + warning semantics under a new, honest name + (`a_pathological_soft_break_is_skipped_and_recorded_as_ir_override`). +9. **Deferred refinements** (named, not implied): per-system justification + (stretching the soft springs so every full system ends at the right + margin); the vertical spring solve (band heights are carried, not yet + renegotiated; systems stack by real content extents); widow/orphan control + and optimal/lookahead casting-off quality (a `Standard`-tier concern, with + `casting_off_quality` in the metric vector); casting-off caching / + incremental re-cast (the spec's incremental-layout section names the + casting-off cache; `solve_incremental` currently re-solves from scratch, + which remains observationally equivalent); per-system clef/key restatement + (cautionary signatures at system starts, `SynthesisKind::Cautionary`); + multi-system-aware x→time inversion for editor click-to-insert + (`epiphany-editor-core`'s `position_at` interpolates one global x axis and + is correct only within the first system of a wrapped region). + ## Pass 12 candidates See `spec/PASS12_BATCH.md` (rows P12-I1, P12-I2, P12-I3) — all three are now @@ -85,3 +189,35 @@ resolved: - **P12-I3 (resolved by I-4a)** — `BRAVURA_METRICS` is re-extracted from the same SHA-pinned `bravura-1.392` font the outlines come from, with bboxes rounded outward to contain the drawn ink (a `render-svg` test proves containment). + +### New candidates from the casting-off slice (proposed rows; spec not edited) + +- **P12 (proposed) — `Canvas.layout_defaults` is named but never defined.** The + spec references layout defaults ("paper size, margins") on the canvas, but no + chapter defines the type, its units, or its defaulting rules, and the core + graph does not carry it. Proposal: define `CanvasLayoutDefaults { page_size: + Size2D, margins: Margins }` in staff spaces in the data-model chapter, + staged to the **data-model schema major** (adding the field changes the + canonical graph encoding); until then, page geometry is a solver parameter + (this crate's `PageGeometry`) and the spec should say a solver MAY default + it. +- **P12 (proposed) — break-constraint satisfaction semantics.** Chapter 7 + defines `SystemBreakAt { slot }` but not what geometric fact makes it + *satisfied*. This crate pins: satisfied iff the final layout **starts a + system at that slot** (page analog for `PageBreakAt`); a region's first slot + is trivially at a boundary. The spec should ratify (or correct) this + predicate, since `Unsatisfiable`-vs-`Solved` conformance hangs on it. +- **P12 (proposed) — user-override attribution across IR stages.** The decision + record for an honoured break must cite `DecisionSource::UserOverride(id)`, + but the normative `LayoutConstraint` carries no origin, so the override id + has no channel from the logical stage's `EngravingOverride` to the solver. + This implementation carries a non-canonical `break_origins` sidecar on + `ConstrainedLayoutIR`; the spec should bless that channel (or widen the + normalized constraint record). +- **P12 (proposed) — synthesis kind for split continuations.** Casting-off + splits region-spanning strokes (staff lines) at system boundaries; the + segments in later systems are engraver-synthesized objects whose kind the + normative `SynthesisKind` set does not name (`EngravedBreak` is the break + itself, not its artefacts). Carried as + `Registered(SYSTEM_CONTINUATION_SYNTHESIS)`; the spec should either add a + continuation kind or bless the registered id. diff --git a/crates/epiphany-engrave/README.md b/crates/epiphany-engrave/README.md index 1879bfe..8b678ae 100644 --- a/crates/epiphany-engrave/README.md +++ b/crates/epiphany-engrave/README.md @@ -6,29 +6,38 @@ production-side replacement for `epiphany-layout-ir`'s interface-only `StubSolver`; the two live in separate crates so the spec's core/product boundary stays sharp. -## Status: honest scaffold (renderer-against-stub phase) - -Per the QUICKSTART development pattern, Agent I builds the SVG renderer -([`epiphany-render-svg`](../epiphany-render-svg)) against the **stub solver** -first, then grows this crate into the real two-pass spring solver. This commit is -the first increment: +## Status: `Minimal` tier, with casting-off - `Engraver` runs a deterministic **horizontal spacing pass** (the first axis of - the planned two-pass spring layout): each spring slot is placed left-to-right by - its preferred width instead of being echoed verbatim. -- It honestly reports `SolverTier::Stub` — it does not yet evaluate the IR's - declared hard constraints or compute quality metrics, so it has not earned - `Minimal`. It is promoted to `Minimal` in the change that lands real constraint - satisfaction. + the two-pass spring layout): each spring slot is placed left-to-right by a + collision-aware advance derived from real glyph bearings. +- A **casting-off pass** (Phase 3's layout track) then breaks the spaced line + into **systems** at measure boundaries (greedy first-fit against a + `PageGeometry` — default A4 portrait at an 8 mm staff), stacks systems + vertically at the vertical-band model's inter-system gap, assigns **pages** + by content height, and populates the real `ResolvedPage`/`ResolvedSystem` + tree. Every position is baked into a single y-up world frame (pages stacked + vertically), so the SVG renderer and hit-testing consume the flat + glyph/stroke lists unchanged. +- The IR's declared constraints are **evaluated** — geometric families against + the pre-casting spaced frame, break constraints against the final break + structure (hard breaks are always honoured; a pathological soft break is + skipped and recorded as an `IrOverride` decision). Chosen breaks are recorded + as `EngravingDecision`s with `SynthesisKind::EngravedBreak` targets, + attributed to the user override that requested them when one did. +- It reports `SolverTier::Minimal`: hard constraints (break family included) + satisfied, **no optimality claim** — the quality-metric vector stays the + honest all-worst placeholder until the Quality Metric Catalog lands. -The vertical spring pass, soft-constraint solve, hard-constraint evaluation, and -the quality-metric vector are the next-phase / Phase-3 work. See `DECISIONS.md`. +Deferred: the vertical soft-spring solve, per-system justification/stretch, +optimal break search, widow/orphan control, and casting-off caching. See +`DECISIONS.md`. ```rust use epiphany_engrave::Engraver; use epiphany_layout_ir::{ConstraintSolver, SolverConfig}; -let report = Engraver.solve(&constrained_ir, &SolverConfig::default()); -assert!(report.satisfied_hard_constraints); // for constraint-free stub-pipeline input -let resolved = report.layout; // hand to epiphany-render-svg +let report = Engraver::default().solve(&constrained_ir, &SolverConfig::default()); +assert!(report.satisfied_hard_constraints); +let resolved = report.layout; // real pages/systems; hand to epiphany-render-svg ``` diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs new file mode 100644 index 0000000..12dbc4e --- /dev/null +++ b/crates/epiphany-engrave/src/casting.rs @@ -0,0 +1,1187 @@ +//! The **casting-off pass** — Minimal-tier system breaking, vertical stacking, +//! and page assignment (Chapter 9 §"The Constraint-Solving Stage": the solver +//! "resolve\[s\] page and system breaks"; Chapter 7 §"ResolvedLayoutIR" defines +//! the page/system tree this pass populates). +//! +//! ## The algorithm (greedy first-fit) +//! +//! [`SolverTier::Minimal`](epiphany_layout_ir::SolverTier) requires the break +//! constraint family to be supported and every hard constraint satisfied (or an +//! honest `Unsatisfiable`); it makes **no optimality claim**, so casting-off is +//! a deterministic greedy first-fit, not an optimal (Knuth–Plass-style) break +//! search: +//! +//! 1. **System breaking.** Per region, walk the spaced spring-slot columns in x +//! order. Break into systems at **measure boundaries** — the barline columns +//! (`to_constrained` draws each measure's barline at its start column; the +//! region-final barline closes the region and is never a break candidate) — +//! whenever the measure beginning at a barline would overflow the page +//! content width. A **hard** `SystemBreakAt`/`PageBreakAt` is *always* +//! honoured at its slot (the slot begins a new system/page); a **soft** one +//! is honoured unless doing so would close a system with no musical content +//! (no notehead/rest column) — the documented exceptional path, recorded as +//! an [`EngravingDecision`] with [`DecisionSource::IrOverride`] per the +//! spec's override-resolution rule (an unhonoured override is recorded, not +//! silently dropped). A region with no measures has no automatic break +//! candidates: it stays one (possibly overfull) system unless breaks force +//! otherwise. A single measure wider than the page yields an overfull +//! system — Minimal does not break mid-measure on its own. +//! 2. **Vertical stacking.** Each system's height is its real content extent +//! (glyph boxes plus stroke extents — the vertical spring solve that would +//! renegotiate band heights is deferred, so the constrained `y` geometry is +//! authoritative); consecutive systems are separated by the vertical-band +//! model's **inter-system gap** ([`VerticalBand::inter_system_gap`], the +//! preferred height — genuinely read from the band constructor so the two +//! cannot drift). Systems that no longer fit the page content height start +//! the next page. +//! 3. **Page assignment and the world frame.** Pages stack **vertically in one +//! world**: page *n*'s top edge sits [`INTER_PAGE_GAP`] staff spaces below +//! page *n−1*'s bottom edge, page 1's top-left corner at the origin (world +//! is y-up, so pages grow downward in −y). Every glyph and stroke position +//! is **baked** into this single world frame (each system is translated +//! rigidly: x back to the left margin, y to its stacked position), so the +//! flat glyph/stroke lists remain the renderer's and hit-tester's single +//! coordinate space — no per-page transform exists anywhere downstream. +//! +//! ## Region-spanning strokes +//! +//! A stroke confined to one system (a stem, a ledger, a barline-anchored mark) +//! translates rigidly with it. A stroke spanning several systems — in practice +//! the five staff lines, which `to_constrained` draws across the whole region — +//! is **split** at the system boundaries: the first segment keeps the original +//! stroke's exact provenance (so the round-trip's preservation contract holds), +//! and each later segment is engraver-**synthesized** from the same source +//! ([`SynthesisKind::Registered`] under [`SYSTEM_CONTINUATION_SYNTHESIS`], the +//! codebase's convention for a synthesis kind the normative vocabulary does not +//! name), keyed by [`continuation_instance_key`] so segments of different lines +//! can never collide. +//! +//! ## Default page geometry +//! +//! The spec names `Canvas.layout_defaults` ("paper size, margins") but does not +//! define its type, and the core graph deliberately does not carry it yet (the +//! graph home is staged to the data-model schema major — see `DECISIONS.md`), +//! so page geometry is an **engraver-side parameter** ([`PageGeometry`], a +//! constructor argument of [`crate::Engraver`]) with a documented default; see +//! [`PageGeometry::default`] for the arithmetic. + +use std::collections::{BTreeMap, BTreeSet}; + +use epiphany_core::{StaffId, TypedObjectId}; +use epiphany_layout_ir::{ + continuation_instance_key, is_rigid_width_stroke, synthesized_layout_id, BreakClass, BreakKind, + ConstrainedLayoutIR, DecisionSource, EngravingDecision, EngravingDecisionKind, + EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId, Margins, Point, + Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem, + Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, + SynthesisRegistryId, VerticalBand, VerticalBandId, +}; + +use crate::owning_glyph; + +/// The registry id for the engraver's **system-continuation synthesis**: the +/// segment of a region-spanning stroke (a staff line) that casting-off places +/// in a system after the stroke's first. The normative [`SynthesisKind`] set +/// names no purely visual continuation rule, so — like the constrained stage's +/// staff-line/ledger/accidental syntheses — it is carried as a `Registered` +/// extension kind (Chapter 7 §"Behavior Under Unknown Extensions"). +pub const SYSTEM_CONTINUATION_SYNTHESIS: SynthesisRegistryId = + SynthesisRegistryId(0x5359_5354_4D53_4547); // "SYSTMSEG" + +/// The vertical gap between consecutive **pages** in the single world frame, in +/// staff spaces. Pages are separate physical sheets; this gap exists only in +/// the continuous scroll-like world the renderer and hit-tester share, so it is +/// a presentation constant, not engraving geometry. +pub const INTER_PAGE_GAP: f32 = 8.0; + +/// Namespace bit for a synthesized *system* provenance instance key (a region's +/// second and later systems), disjoint from the page namespace below and — by +/// 128-bit-hash construction — from the slot-identity keys of break decisions. +const KEY_NS_SYSTEM: u128 = 1; +/// Namespace bit for a synthesized *page* provenance instance key. +const KEY_NS_PAGE: u128 = 2; + +/// Page geometry the engraver casts off against: the page size and margins, in +/// staff spaces (Chapter 7 §7.2: IR coordinates are staff spaces). A parameter +/// of [`crate::Engraver`] because the score graph has no home for it yet — the +/// spec's `Canvas.layout_defaults` is named but never defined, and adding a +/// graph field is a data-model schema-major change (see `DECISIONS.md`). +#[derive(Copy, Clone, PartialEq, Debug)] +pub struct PageGeometry { + /// Full page size, in staff spaces. + pub size: Size2D, + /// Page margins, in staff spaces. + pub margins: Margins, +} + +impl PageGeometry { + /// The horizontal content extent a system may fill: page width minus the + /// left and right margins. Non-positive geometry disables automatic + /// wrapping (treated as unbounded) rather than failing the solve. + pub fn content_width(&self) -> f32 { + self.size.width.0 - self.margins.left.0 - self.margins.right.0 + } + + /// The vertical content extent a page may fill: page height minus the top + /// and bottom margins. Non-positive geometry disables page overflow + /// (treated as unbounded) rather than failing the solve. + pub fn content_height(&self) -> f32 { + self.size.height.0 - self.margins.top.0 - self.margins.bottom.0 + } +} + +impl Default for PageGeometry { + /// **A4 portrait at an 8 mm staff height** (rastral ≈ size 1, a common + /// full-size instrumental-part raster), 15 mm margins. The arithmetic, with + /// 1 staff space = staff height / 4 = 2.0 mm: + /// + /// * page: 210 mm × 297 mm → **105 × 148.5** staff spaces; + /// * margins: 15 mm each → **7.5** staff spaces; + /// * content area: 180 mm × 267 mm → **90 × 133.5** staff spaces. + /// + /// 90 staff spaces of content width wraps the QUICKSTART's ten-measure + /// hand-off fixture (whose spaced width is ≈ 99 staff spaces) into two + /// systems — an honest multi-system default rather than one that only ever + /// produces the degenerate single line. + fn default() -> Self { + PageGeometry { + size: Size2D { + width: StaffSpace(105.0), + height: StaffSpace(148.5), + }, + margins: Margins { + top: StaffSpace(7.5), + right: StaffSpace(7.5), + bottom: StaffSpace(7.5), + left: StaffSpace(7.5), + }, + } + } +} + +/// What the casting-off pass produced: the final world-frame geometry, the +/// populated page/system tree, the engraver's appended break decisions, and the +/// break structure the constraint evaluation consults. +pub(crate) struct CastLayout { + /// Final glyphs, in input order, positions baked into the world frame. + pub glyphs: Vec, + /// Final strokes: the input strokes in order (each translated with its + /// system; a system-spanning stroke replaced by its first segment), then + /// the synthesized continuation segments. + pub strokes: Vec, + /// The populated page tree (empty when the input declares no regions). + pub pages: Vec, + /// Break decisions this pass made (chosen breaks in reading order, then + /// the skipped-soft `IrOverride` records in walk order). + pub decisions: Vec, + /// Slots at which the final layout breaks: the first slot of every system. + pub system_start_slots: BTreeSet, + /// Slots at which a page begins: the first slot of each page's first system. + pub page_start_slots: BTreeSet, +} + +/// One realized spring slot in spaced (pre-casting) coordinates, with the +/// classification the greedy walk needs. +struct SlotInfo { + id: SpringSlotId, + /// Reference x: the first member glyph's spaced baseline. + x: f32, + /// Leftmost content edge (member glyph boxes plus their rigid strokes). + lo: f32, + /// Rightmost content edge. + hi: f32, + /// Member glyph indices into the (parallel) input/spaced glyph vectors. + members: Vec, + /// The column carries a barline glyph — a measure boundary. + barline: bool, + /// The column carries the region-final barline (never a break candidate). + final_barline: bool, + /// The column carries musical content (a notehead or a rest). + note: bool, + /// The directly-manifested barline glyph of a measure *start* (glyph + /// index), for the per-system measure records. `None` at the final + /// barline: that measure's start is not marked by any column in this + /// projection, so its record is omitted rather than fabricated. + measure_barline: Option, +} + +/// A break requirement a constraint declares at a slot. +#[derive(Copy, Clone)] +struct BreakReq { + page: bool, + hard: bool, +} + +/// The boundary decision that opened a system (absent at a region's first). +#[derive(Copy, Clone)] +struct Boundary { + slot: SpringSlotId, + source: DecisionSource, +} + +/// One cast-off system: which region it slices and which of that region's +/// slots it carries. +struct SystemPlan { + region: usize, + /// Region-local ordinal (0-based). + local: usize, + /// Indices into the region's ordered slot vector. + slots: Vec, + boundary: Option, + /// A page must start at this system (a page-break request sits here). + page_forced: bool, + /// Attribution for a forced page start (the page-break decision's source). + page_source: DecisionSource, +} + +/// A stroke's casting fate: ride one system rigidly, or split at system +/// boundaries. +enum StrokeFate { + /// Translate the whole stroke with this system (`None`: not covered by any + /// region — left untransformed in the spaced frame, on no page). + Rigid(Option), + /// Per-system segments, ascending system order: `(system, from, to)` in + /// spaced coordinates. + Split(Vec<(usize, Point, Point)>), +} + +/// The content extent of a system in spaced (pre-casting) coordinates. +#[derive(Copy, Clone)] +struct Extent { + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, + any: bool, +} + +impl Extent { + fn empty() -> Self { + Extent { + min_x: f32::INFINITY, + min_y: f32::INFINITY, + max_x: f32::NEG_INFINITY, + max_y: f32::NEG_INFINITY, + any: false, + } + } + + fn add(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) { + if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) { + self.min_x = self.min_x.min(x0.min(x1)); + self.max_x = self.max_x.max(x0.max(x1)); + self.min_y = self.min_y.min(y0.min(y1)); + self.max_y = self.max_y.max(y0.max(y1)); + self.any = true; + } + } + + /// Normalized: a content-less system is a zero box at the origin. + fn normalized(self) -> Self { + if self.any { + self + } else { + Extent { + min_x: 0.0, + min_y: 0.0, + max_x: 0.0, + max_y: 0.0, + any: false, + } + } + } +} + +/// The MUSCLOID target of an engraved break decision: synthesized from the +/// owning region's source under [`SynthesisKind::EngravedBreak`], keyed by the +/// breaking slot's identity (the slot id is itself content-derived from the +/// region and its column, so the key is the column's semantic identity, never a +/// layout-position ordinal). +fn break_target(region_source: TypedObjectId, slot: SpringSlotId) -> LayoutObjectId { + synthesized_layout_id( + ®ion_source, + SynthesisKind::EngravedBreak, + SynthesisInstanceKey(slot.0), + ) +} + +/// The decision source for a break honoured at `slot`: the user override that +/// asked for it when the projection recorded one, else `Automatic`. +fn origin_source( + origins: &BTreeMap<(u128, bool), EngravingOverrideId>, + slot: SpringSlotId, + page: bool, +) -> DecisionSource { + match origins.get(&(slot.0, page)) { + Some(id) => DecisionSource::UserOverride(*id), + None => DecisionSource::Automatic, + } +} + +/// Casts the spaced layout off into systems and pages. Pure and deterministic: +/// a function of the input IR, the spaced geometry, and the page geometry. +pub(crate) fn cast_off( + input: &ConstrainedLayoutIR, + spaced_glyphs: &[ResolvedGlyph], + spaced_strokes: &[Stroke], + geometry: &PageGeometry, +) -> CastLayout { + // ---- Slot table (spaced coordinates) -------------------------------- + let mut slots: BTreeMap = BTreeMap::new(); + for (i, (glyph, spaced)) in input.glyphs.iter().zip(spaced_glyphs).enumerate() { + let name = glyph.glyph.as_str(); + let x = spaced.position.x.0; + let lo = x + glyph.bounding_box.left.0; + let hi = x + glyph.bounding_box.right.0; + let entry = slots.entry(glyph.horizontal_slot).or_insert(SlotInfo { + id: glyph.horizontal_slot, + x, + lo, + hi, + members: Vec::new(), + barline: false, + final_barline: false, + note: false, + measure_barline: None, + }); + entry.lo = entry.lo.min(lo); + entry.hi = entry.hi.max(hi); + entry.members.push(i); + if name.starts_with("barline") { + entry.barline = true; + if name == "barlineFinal" { + entry.final_barline = true; + } else if entry.measure_barline.is_none() + && glyph.provenance.synthesis.is_none() + && matches!(glyph.provenance.source, TypedObjectId::Measure(_)) + { + entry.measure_barline = Some(i); + } + } + if name.starts_with("notehead") || name.starts_with("rest") { + entry.note = true; + } + } + // Fold each rigid stroke (a ledger line) into its owning slot's extent, so + // an overhanging ledger widens the measure it belongs to (mirrors the + // spacing pass's extent rule). + for (stroke, spaced) in input.strokes.iter().zip(spaced_strokes) { + if !is_rigid_width_stroke(stroke) { + continue; + } + if let Some(glyph) = owning_glyph(stroke, &input.glyphs) { + if let Some(entry) = slots.get_mut(&glyph.horizontal_slot) { + entry.lo = entry.lo.min(spaced.from.x.0.min(spaced.to.x.0)); + entry.hi = entry.hi.max(spaced.from.x.0.max(spaced.to.x.0)); + } + } + } + + // ---- Region partition ------------------------------------------------ + let mut region_of_glyph: BTreeMap = BTreeMap::new(); + for (r, region) in input.regions.iter().enumerate() { + for id in ®ion.glyphs { + region_of_glyph.entry(*id).or_insert(r); + } + } + let mut region_slots: Vec> = + (0..input.regions.len()).map(|_| Vec::new()).collect(); + for (_, info) in slots { + let region = info + .members + .first() + .and_then(|&i| region_of_glyph.get(&input.glyphs[i].id())) + .copied(); + // A slot no region claims (out-of-pipeline input) is left out: its + // glyphs stay in the spaced frame, on no page. + if let Some(r) = region { + region_slots[r].push(info); + } + } + for infos in &mut region_slots { + infos.sort_by(|a, b| a.x.total_cmp(&b.x).then_with(|| a.id.cmp(&b.id))); + } + + // ---- Break requirements ---------------------------------------------- + let mut reqs: BTreeMap> = BTreeMap::new(); + for constraint in &input.constraints { + let (slot, page, kind) = match constraint { + LayoutConstraint::SystemBreakAt { slot, kind } => (*slot, false, *kind), + LayoutConstraint::PageBreakAt { slot, kind } => (*slot, true, *kind), + _ => continue, + }; + reqs.entry(slot).or_default().push(BreakReq { + page, + hard: kind == BreakKind::Hard, + }); + } + let mut origins: BTreeMap<(u128, bool), EngravingOverrideId> = BTreeMap::new(); + for origin in &input.break_origins { + origins + .entry((origin.slot.0, origin.class == BreakClass::Page)) + .or_insert(origin.override_id); + } + + // ---- System breaking (greedy first-fit per region) -------------------- + let width_limit = { + let w = geometry.content_width(); + if w > 0.0 { + w + } else { + f32::INFINITY + } + }; + let mut systems: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + for (r, infos) in region_slots.iter().enumerate() { + let region_source = input.regions[r].provenance.source; + walk_region( + r, + infos, + &reqs, + &origins, + region_source, + width_limit, + &mut systems, + &mut skipped, + ); + } + + // ---- Stroke fates ------------------------------------------------------ + // Which system each slot landed in, and each region's slot span / per-system + // clip intervals (the interior cut points for system-spanning strokes). + let mut system_of_slot: BTreeMap = BTreeMap::new(); + for (s, plan) in systems.iter().enumerate() { + for &i in &plan.slots { + system_of_slot.insert(region_slots[plan.region][i].id, s); + } + } + let region_spans: Vec> = region_slots + .iter() + .map(|infos| { + infos + .iter() + .map(|s| (s.lo, s.hi)) + .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))) + }) + .collect(); + let mut region_systems: Vec> = vec![Vec::new(); input.regions.len()]; + for (s, plan) in systems.iter().enumerate() { + region_systems[plan.region].push(s); + } + let mut clips: Vec<(f32, f32)> = vec![(f32::NEG_INFINITY, f32::INFINITY); systems.len()]; + for (r, sys_of_region) in region_systems.iter().enumerate() { + let last = sys_of_region.len().saturating_sub(1); + for (local, &s) in sys_of_region.iter().enumerate() { + let lo = if local == 0 { + f32::NEG_INFINITY + } else { + systems[s] + .slots + .iter() + .map(|&i| region_slots[r][i].lo) + .fold(f32::INFINITY, f32::min) + }; + let hi = if local == last { + f32::INFINITY + } else { + systems[s] + .slots + .iter() + .map(|&i| region_slots[r][i].hi) + .fold(f32::NEG_INFINITY, f32::max) + }; + clips[s] = (lo, hi); + } + } + let fates: Vec = input + .strokes + .iter() + .zip(spaced_strokes) + .map(|(stroke, spaced)| { + stroke_fate( + stroke, + spaced, + input, + &system_of_slot, + ®ion_spans, + ®ion_systems, + &clips, + ) + }) + .collect(); + + // ---- System extents ---------------------------------------------------- + let mut extents: Vec = vec![Extent::empty(); systems.len()]; + for (s, plan) in systems.iter().enumerate() { + for &i in &plan.slots { + for &g in ®ion_slots[plan.region][i].members { + let glyph = &spaced_glyphs[g]; + let (x, y) = (glyph.position.x.0, glyph.position.y.0); + extents[s].add( + x + glyph.bounding_box.left.0, + y + glyph.bounding_box.bottom.0, + x + glyph.bounding_box.right.0, + y + glyph.bounding_box.top.0, + ); + } + } + } + for (fate, spaced) in fates.iter().zip(spaced_strokes) { + let half = (spaced.thickness.0 * 0.5).max(0.0); + match fate { + StrokeFate::Rigid(Some(s)) => extents[*s].add( + spaced.from.x.0 - half, + spaced.from.y.0.min(spaced.to.y.0) - half, + spaced.to.x.0 + half, + spaced.from.y.0.max(spaced.to.y.0) + half, + ), + StrokeFate::Rigid(None) => {} + StrokeFate::Split(segments) => { + for (s, from, to) in segments { + extents[*s].add( + from.x.0 - half, + from.y.0.min(to.y.0) - half, + to.x.0 + half, + from.y.0.max(to.y.0) + half, + ); + } + } + } + } + let extents: Vec = extents.into_iter().map(Extent::normalized).collect(); + + // ---- Vertical stacking and page assignment ---------------------------- + // The inter-system spacing comes from the vertical-band model's own + // constructor, so the casting-off gap and the band spring cannot drift. + let gap = VerticalBand::inter_system_gap(VerticalBandId(0)) + .preferred_height + .0; + let content_height = geometry.content_height(); + let bounded = content_height > 0.0; + let mut placements: Vec<(f32, f32)> = Vec::with_capacity(systems.len()); + let mut page_systems: Vec> = Vec::new(); + let mut cursor = 0.0_f32; + let mut page_floor = 0.0_f32; + for (s, plan) in systems.iter().enumerate() { + let ext = &extents[s]; + let height = ext.max_y - ext.min_y; + // Every opened page immediately receives a system, so an overflow test + // against a non-empty page list never opens an empty page — a system + // taller than a whole page stays (overfull) on the page it opens. + let overflow = bounded && !page_systems.is_empty() && cursor - height < page_floor; + if page_systems.is_empty() || plan.page_forced || overflow { + let p = page_systems.len(); + cursor = page_top_content(p, geometry); + page_floor = cursor - content_height.max(0.0); + page_systems.push(Vec::new()); + } + let dx = geometry.margins.left.0 - ext.min_x; + let dy = cursor - ext.max_y; + placements.push((dx, dy)); + page_systems + .last_mut() + .expect("a page was opened above") + .push(s); + cursor -= height + gap; + } + + // ---- Break structure and decisions ------------------------------------- + let mut system_start_slots = BTreeSet::new(); + for plan in &systems { + if let Some(&i) = plan.slots.first() { + system_start_slots.insert(region_slots[plan.region][i].id); + } + } + let mut page_start_slots = BTreeSet::new(); + let mut decisions = Vec::new(); + for (p, on_page) in page_systems.iter().enumerate() { + for (j, &s) in on_page.iter().enumerate() { + let plan = &systems[s]; + let starts_page = j == 0; + if starts_page { + if let Some(&i) = plan.slots.first() { + page_start_slots.insert(region_slots[plan.region][i].id); + } + } + let region_source = input.regions[plan.region].provenance.source; + if let Some(boundary) = plan.boundary { + // A chosen intra-region break: a page decision when the system + // actually opens a page, a system decision otherwise. + decisions.push(EngravingDecision::with_source( + break_target(region_source, boundary.slot), + if starts_page { + EngravingDecisionKind::PageBreak + } else { + EngravingDecisionKind::SystemBreak + }, + boundary.source, + )); + } else if starts_page && p > 0 { + // A later page opening at a region's first system: the page + // start is itself an engraved decision (forced or overflow). + if let Some(&i) = plan.slots.first() { + decisions.push(EngravingDecision::with_source( + break_target(region_source, region_slots[plan.region][i].id), + EngravingDecisionKind::PageBreak, + plan.page_source, + )); + } + } + } + } + decisions.extend(skipped); + + // ---- Bake the world frame ---------------------------------------------- + let glyphs: Vec = spaced_glyphs + .iter() + .zip(&input.glyphs) + .map(|(spaced, glyph)| { + let (dx, dy) = system_of_slot + .get(&glyph.horizontal_slot) + .map(|&s| placements[s]) + .unwrap_or((0.0, 0.0)); + ResolvedGlyph { + position: Point::new(spaced.position.x.0 + dx, spaced.position.y.0 + dy), + ..spaced.clone() + } + }) + .collect(); + + // Per-system staff-line marks, for the resolved staff records below. + let mut staff_marks: BTreeMap<(usize, StaffId), StaffAgg> = BTreeMap::new(); + let mut strokes: Vec = Vec::with_capacity(spaced_strokes.len()); + let mut continuations: Vec = Vec::new(); + for (spaced, fate) in spaced_strokes.iter().zip(&fates) { + match fate { + StrokeFate::Rigid(sys) => { + let (dx, dy) = sys.map(|s| placements[s]).unwrap_or((0.0, 0.0)); + let stroke = translated(spaced, dx, dy); + if let (Some(s), TypedObjectId::Staff(staff)) = (sys, spaced.provenance.source) { + mark_staff(&mut staff_marks, *s, staff, &stroke); + } + strokes.push(stroke); + } + StrokeFate::Split(segments) => { + for (k, (s, from, to)) in segments.iter().enumerate() { + let (dx, dy) = placements[*s]; + let provenance = if k == 0 { + // The first segment carries the original stroke's exact + // provenance: the object survives, re-shaped. + spaced.provenance.clone() + } else { + Provenance::synthesized( + spaced.provenance.source, + SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS), + continuation_instance_key(spaced.provenance.stable_id, k as u32), + spaced.provenance.dependencies.clone(), + ) + }; + let stroke = Stroke { + provenance, + from: Point::new(from.x.0 + dx, from.y.0 + dy), + to: Point::new(to.x.0 + dx, to.y.0 + dy), + thickness: spaced.thickness, + layer: spaced.layer, + style: spaced.style, + }; + if let TypedObjectId::Staff(staff) = spaced.provenance.source { + mark_staff(&mut staff_marks, *s, staff, &stroke); + } + if k == 0 { + strokes.push(stroke); + } else { + continuations.push(stroke); + } + } + } + } + } + strokes.extend(continuations); + + // ---- The resolved page tree --------------------------------------------- + let resolved_systems: Vec = systems + .iter() + .enumerate() + .map(|(s, plan)| { + build_system( + s, + plan, + input, + ®ion_slots, + &extents, + &placements, + &staff_marks, + ) + }) + .collect(); + let mut resolved_systems: Vec> = + resolved_systems.into_iter().map(Some).collect(); + let pages: Vec = page_systems + .iter() + .enumerate() + .map(|(p, on_page)| { + let first_region = systems[on_page[0]].region; + let region_provenance = &input.regions[first_region].provenance; + let provenance = if p == 0 { + // Page 1 carries the first region's own provenance, as the + // degenerate single-page output always did. + input.regions[0].provenance.clone() + } else { + Provenance::synthesized( + region_provenance.source, + SynthesisKind::EngravedBreak, + SynthesisInstanceKey((KEY_NS_PAGE << 64) | (p as u128 + 1)), + region_provenance.dependencies.clone(), + ) + }; + ResolvedPage { + provenance, + number: p as u32 + 1, + size: geometry.size, + margins: geometry.margins, + systems: on_page + .iter() + .map(|&s| resolved_systems[s].take().expect("each system on one page")) + .collect(), + // Nothing in the Minimal pipeline is a page-level free object + // (region content is all system-bound); left empty rather than + // fabricated. + free_objects: Vec::new(), + } + }) + .collect(); + + CastLayout { + glyphs, + strokes, + pages, + decisions, + system_start_slots, + page_start_slots, + } +} + +/// The world-frame y of page `p`'s content top: pages stack downward from the +/// origin, each a full page height plus [`INTER_PAGE_GAP`] below the previous. +fn page_top_content(p: usize, geometry: &PageGeometry) -> f32 { + -(p as f32) * (geometry.size.height.0 + INTER_PAGE_GAP) - geometry.margins.top.0 +} + +/// Greedy first-fit walk over one region's slots (see the module docs). +#[allow(clippy::too_many_arguments)] +fn walk_region( + region: usize, + slots: &[SlotInfo], + reqs: &BTreeMap>, + origins: &BTreeMap<(u128, bool), EngravingOverrideId>, + region_source: TypedObjectId, + width_limit: f32, + systems: &mut Vec, + skipped: &mut Vec, +) { + // Measure look-ahead: `chunk_hi[i]` is the rightmost content edge of the + // chunk beginning at slot `i` — through the slot before the next breakable + // barline (the region-final barline closes the last chunk, so it never + // starts one). + let breakable = |slot: &SlotInfo| slot.barline && !slot.final_barline; + let mut chunk_hi = vec![f32::NEG_INFINITY; slots.len()]; + for i in (0..slots.len()).rev() { + let next = if i + 1 < slots.len() && !breakable(&slots[i + 1]) { + chunk_hi[i + 1] + } else { + f32::NEG_INFINITY + }; + chunk_hi[i] = slots[i].hi.max(next); + } + + let mut local = 0usize; + let mut current: Vec = Vec::new(); + let mut has_note = false; + let mut current_lo = f32::INFINITY; + let mut open_boundary: Option = None; + let mut open_page_forced = false; + let mut open_page_source = DecisionSource::Automatic; + + for (i, slot) in slots.iter().enumerate() { + let slot_reqs = reqs.get(&slot.id).map(Vec::as_slice).unwrap_or(&[]); + if current.is_empty() { + // The region's first slot is already at a system boundary, so a + // system break here is trivially honoured; a page break still + // forces this (first) system onto a fresh page. + for req in slot_reqs { + if req.page { + open_page_forced = true; + if open_page_source == DecisionSource::Automatic { + open_page_source = origin_source(origins, slot.id, true); + } + } + } + current.push(i); + has_note = slot.note; + current_lo = slot.lo; + continue; + } + let mut break_here = false; + let mut page_here = false; + let mut source = DecisionSource::Automatic; + for req in slot_reqs { + if !req.hard && !has_note { + // The documented exceptional path: honouring this *soft* break + // would close a system with no musical content (e.g. a bare + // clef/barline line). It is skipped, and the unhonoured + // override is recorded as an IR-stage-overridden decision + // (never silently dropped). + skipped.push(EngravingDecision::with_source( + break_target(region_source, slot.id), + if req.page { + EngravingDecisionKind::PageBreak + } else { + EngravingDecisionKind::SystemBreak + }, + DecisionSource::IrOverride, + )); + continue; + } + break_here = true; + page_here |= req.page; + if !matches!(source, DecisionSource::UserOverride(_)) { + source = origin_source(origins, slot.id, req.page); + } + } + // Greedy first-fit: at a measure boundary, break when the measure + // beginning here would overflow the content width. + if !break_here && breakable(slot) && has_note && chunk_hi[i] - current_lo > width_limit { + break_here = true; + } + if break_here { + systems.push(SystemPlan { + region, + local, + slots: std::mem::take(&mut current), + boundary: open_boundary.take(), + page_forced: open_page_forced, + page_source: open_page_source, + }); + local += 1; + open_boundary = Some(Boundary { + slot: slot.id, + source, + }); + open_page_forced = page_here; + open_page_source = if page_here { + source + } else { + DecisionSource::Automatic + }; + current.push(i); + has_note = slot.note; + current_lo = slot.lo; + } else { + current.push(i); + has_note |= slot.note; + current_lo = current_lo.min(slot.lo); + } + } + // The region's last system — or, for a region with no slots at all, its + // single (empty) system, preserving one-system-per-region as the minimum. + systems.push(SystemPlan { + region, + local, + slots: current, + boundary: open_boundary, + page_forced: open_page_forced, + page_source: open_page_source, + }); +} + +/// Decides how a stroke rides the cast systems (see [`StrokeFate`]). +fn stroke_fate( + stroke: &Stroke, + spaced: &Stroke, + input: &ConstrainedLayoutIR, + system_of_slot: &BTreeMap, + region_spans: &[Option<(f32, f32)>], + region_systems: &[Vec], + clips: &[(f32, f32)], +) -> StrokeFate { + // A rigid-width stroke (a ledger line) rides its owning glyph's system, so + // it translates by exactly the same delta as its notehead. + if is_rigid_width_stroke(stroke) { + if let Some(glyph) = owning_glyph(stroke, &input.glyphs) { + return StrokeFate::Rigid(system_of_slot.get(&glyph.horizontal_slot).copied()); + } + } + let lo = spaced.from.x.0.min(spaced.to.x.0); + let hi = spaced.from.x.0.max(spaced.to.x.0); + // The owning region: the one whose slot span is nearest (ties to the first). + let mut best: Option<(usize, f32)> = None; + for (r, span) in region_spans.iter().enumerate() { + let Some((rlo, rhi)) = span else { continue }; + let distance = if hi < *rlo { + rlo - hi + } else if lo > *rhi { + lo - rhi + } else { + 0.0 + }; + if best.map_or(true, |(_, d)| distance < d) { + best = Some((r, distance)); + } + } + let Some((region, _)) = best else { + return StrokeFate::Rigid(None); + }; + // The systems of that region the stroke's span overlaps. + let overlapped: Vec = region_systems[region] + .iter() + .copied() + .filter(|&s| lo <= clips[s].1 && hi >= clips[s].0) + .collect(); + match overlapped.len() { + 0 => { + // In the sliver between two systems' content: nearest system. + let nearest = region_systems[region] + .iter() + .copied() + .min_by(|&a, &b| { + let da = interval_distance(lo, hi, clips[a]); + let db = interval_distance(lo, hi, clips[b]); + da.total_cmp(&db).then(a.cmp(&b)) + }) + .expect("every region has at least one system"); + StrokeFate::Rigid(Some(nearest)) + } + 1 => StrokeFate::Rigid(Some(overlapped[0])), + _ => { + // A system-spanning stroke (a staff line): one segment per system, + // cut at the systems' content edges, y interpolated along the + // stroke so a (hypothetical) sloped spanner splits consistently. + let (x0, y0) = (spaced.from.x.0, spaced.from.y.0); + let (x1, y1) = (spaced.to.x.0, spaced.to.y.0); + let point_at = |x: f32| -> Point { + if (x1 - x0).abs() < f32::EPSILON { + Point::new(x, y0) + } else { + let t = (x - x0) / (x1 - x0); + Point::new(x, y0 + t * (y1 - y0)) + } + }; + let segments = overlapped + .into_iter() + .map(|s| { + let a = lo.max(clips[s].0); + let b = hi.min(clips[s].1); + (s, point_at(a), point_at(b)) + }) + .collect(); + StrokeFate::Split(segments) + } + } +} + +/// Distance from the span `[lo, hi]` to a clip interval (0 when they overlap). +fn interval_distance(lo: f32, hi: f32, clip: (f32, f32)) -> f32 { + if hi < clip.0 { + clip.0 - hi + } else if lo > clip.1 { + lo - clip.1 + } else { + 0.0 + } +} + +/// A stroke translated rigidly by `(dx, dy)`. +fn translated(stroke: &Stroke, dx: f32, dy: f32) -> Stroke { + Stroke { + provenance: stroke.provenance.clone(), + from: Point::new(stroke.from.x.0 + dx, stroke.from.y.0 + dy), + to: Point::new(stroke.to.x.0 + dx, stroke.to.y.0 + dy), + thickness: stroke.thickness, + layer: stroke.layer, + style: stroke.style, + } +} + +/// Accumulated staff-line geometry within one system, for the resolved staff +/// record: the extent of the staff's line segments and the provenance of its +/// bottom line (the segment that anchors the staff in this system). +struct StaffAgg { + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, + bottom: (f32, Provenance), +} + +/// Folds a world-frame staff-line stroke into its `(system, staff)` aggregate. +fn mark_staff( + marks: &mut BTreeMap<(usize, StaffId), StaffAgg>, + system: usize, + staff: StaffId, + stroke: &Stroke, +) { + let half = (stroke.thickness.0 * 0.5).max(0.0); + let (lo_x, hi_x) = ( + stroke.from.x.0.min(stroke.to.x.0), + stroke.from.x.0.max(stroke.to.x.0), + ); + let (lo_y, hi_y) = ( + stroke.from.y.0.min(stroke.to.y.0) - half, + stroke.from.y.0.max(stroke.to.y.0) + half, + ); + marks + .entry((system, staff)) + .and_modify(|agg| { + agg.min_x = agg.min_x.min(lo_x); + agg.max_x = agg.max_x.max(hi_x); + agg.min_y = agg.min_y.min(lo_y); + agg.max_y = agg.max_y.max(hi_y); + if lo_y < agg.bottom.0 { + agg.bottom = (lo_y, stroke.provenance.clone()); + } + }) + .or_insert_with(|| StaffAgg { + min_x: lo_x, + max_x: hi_x, + min_y: lo_y, + max_y: hi_y, + bottom: (lo_y, stroke.provenance.clone()), + }); +} + +/// Builds one populated [`ResolvedSystem`]: a real world-frame bounding box, a +/// staff record per staff whose lines reach this system (top staff first), and +/// a measure record per measure-start barline column the system carries. What +/// the pipeline does not know is left empty, never fabricated: a staff with no +/// engraved lines yields no staff record, and the final-barline measure (whose +/// start no column marks) yields no measure record. +fn build_system( + system: usize, + plan: &SystemPlan, + input: &ConstrainedLayoutIR, + region_slots: &[Vec], + extents: &[Extent], + placements: &[(f32, f32)], + staff_marks: &BTreeMap<(usize, StaffId), StaffAgg>, +) -> ResolvedSystem { + let region = &input.regions[plan.region]; + let (dx, dy) = placements[system]; + let ext = &extents[system]; + let provenance = if plan.local == 0 { + region.provenance.clone() + } else { + // A region's second and later systems are engraver-created objects: + // synthesized from the region under `EngravedBreak`, keyed by the + // region-local system ordinal in its own key namespace. + Provenance::synthesized( + region.provenance.source, + SynthesisKind::EngravedBreak, + SynthesisInstanceKey((KEY_NS_SYSTEM << 64) | plan.local as u128), + region.provenance.dependencies.clone(), + ) + }; + let bounding_box = Rect { + origin: Point::new(ext.min_x + dx, ext.min_y + dy), + size: Size2D { + width: StaffSpace(ext.max_x - ext.min_x), + height: StaffSpace(ext.max_y - ext.min_y), + }, + }; + + let mut staves: Vec = staff_marks + .range((system, StaffId::from_raw(0))..=(system, StaffId::from_raw(u128::MAX))) + .map(|(&(_, staff), agg)| ResolvedStaff { + provenance: agg.bottom.1.clone(), + staff, + bounding_box: Rect { + origin: Point::new(agg.min_x, agg.min_y), + size: Size2D { + width: StaffSpace(agg.max_x - agg.min_x), + height: StaffSpace(agg.max_y - agg.min_y), + }, + }, + }) + .collect(); + // Top staff first — the reading order of the system. + staves.sort_by(|a, b| { + let top_a = a.bounding_box.origin.y.0 + a.bounding_box.size.height.0; + let top_b = b.bounding_box.origin.y.0 + b.bounding_box.size.height.0; + top_b.total_cmp(&top_a) + }); + + // Measures: each measure-start barline column opens a span that runs to the + // next such column in this system, or to the system's content edge. + let slots = ®ion_slots[plan.region]; + let marks: Vec<(usize, usize)> = plan + .slots + .iter() + .filter_map(|&i| slots[i].measure_barline.map(|g| (i, g))) + .collect(); + let measures: Vec = marks + .iter() + .enumerate() + .filter_map(|(k, &(i, g))| { + let glyph = &input.glyphs[g]; + let TypedObjectId::Measure(measure) = glyph.provenance.source else { + return None; + }; + let start = slots[i].lo; + let end = marks + .get(k + 1) + .map(|&(next, _)| slots[next].lo) + .unwrap_or(ext.max_x); + Some(ResolvedMeasure { + provenance: glyph.provenance.clone(), + measure, + bounding_box: Rect { + origin: Point::new(start + dx, ext.min_y + dy), + size: Size2D { + width: StaffSpace(end - start), + height: StaffSpace(ext.max_y - ext.min_y), + }, + }, + }) + }) + .collect(); + + ResolvedSystem { + provenance, + bounding_box, + staves, + measures, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_geometry_matches_the_documented_arithmetic() { + // A4 at an 8 mm staff: 1 staff space = 2 mm. + let geometry = PageGeometry::default(); + assert_eq!(geometry.size.width.0, 210.0 / 2.0); + assert_eq!(geometry.size.height.0, 297.0 / 2.0); + for margin in [ + geometry.margins.top, + geometry.margins.right, + geometry.margins.bottom, + geometry.margins.left, + ] { + assert_eq!(margin.0, 15.0 / 2.0); + } + assert_eq!(geometry.content_width(), 90.0); + assert_eq!(geometry.content_height(), 133.5); + } + + #[test] + fn pages_stack_downward_with_the_inter_page_gap() { + let geometry = PageGeometry::default(); + assert_eq!(page_top_content(0, &geometry), -7.5); + assert_eq!( + page_top_content(1, &geometry), + -(148.5 + INTER_PAGE_GAP) - 7.5 + ); + } +} diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index aa32924..a4fc8ee 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -9,31 +9,49 @@ //! crates so the core/product boundary stays sharp (`spec/PHASE2_QUICKSTART.md`, //! crate topology). //! -//! ## Phase status — `Minimal` tier +//! ## Phase status — `Minimal` tier, with casting-off //! -//! [`Engraver`] runs a genuine deterministic **horizontal spacing pass** (see -//! the private `spacing` module) — placing each glyph-bearing slot left-to-right by a -//! collision-aware advance (its preferred width floored by the real glyph -//! bearings) — and **evaluates the IR's declared constraints** against the -//! resolved geometry, routed by [`LayoutConstraint::strength`] (Chapter 9 -//! §"Strength Levels"): a violated `Required` constraint (no-collision, -//! alignment, position-within, a hard break, an unverifiable extension -//! constraint) is reported unsatisfied and the solve is -//! [`SolveStatus::Unsatisfiable`]; a violated `Preferred` constraint (a soft -//! break this single-system solve does not honour) surfaces as a +//! [`Engraver`] runs a genuine deterministic **horizontal spacing pass** (the +//! private `spacing` module) — placing each glyph-bearing slot left-to-right by +//! a collision-aware advance (its preferred width floored by the real glyph +//! bearings) — then a **casting-off pass** (the [`casting`] module; Chapter 9 +//! §"The Constraint-Solving Stage": the solver "resolve\[s\] page and system +//! breaks"): greedy first-fit system breaking at measure boundaries against a +//! [`PageGeometry`], vertical system stacking at the vertical-band model's +//! inter-system gap, page assignment by content height, and a real populated +//! page/system tree (Chapter 7 §"ResolvedLayoutIR"). Every chosen break is +//! recorded as an [`epiphany_layout_ir::EngravingDecision`] whose target is a +//! `MUSCLOID` id synthesized under +//! [`epiphany_layout_ir::SynthesisKind::EngravedBreak`], attributed to the user +//! override that asked for it when one did +//! ([`epiphany_layout_ir::DecisionSource::UserOverride`]). +//! +//! The declared constraints are **evaluated**, routed by +//! [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"). Geometric +//! constraints (no-collision, alignment, position-within) are evaluated in the +//! **pre-casting spaced frame** — they are region-frame obligations, and +//! casting-off relocates whole systems by rigid motions that cannot un-satisfy +//! them within a system (see `DECISIONS.md`). Break constraints are evaluated +//! against the **final break structure**: a `SystemBreakAt`/`PageBreakAt` is +//! satisfied iff the cast layout breaks (starts a system/page) at that slot. A +//! violated `Required` constraint makes the solve +//! [`SolveStatus::Unsatisfiable`]; a violated `Preferred` one (a soft break +//! skipped on the documented pathological path) surfaces as a //! [`SolverWarningKind::LargeSoftConstraintViolation`] warning under -//! [`SolveStatus::SolvedWithWarnings`], never a failure. A solve is -//! [`SolveStatus::Solved`] only when every declared constraint holds. +//! [`SolveStatus::SolvedWithWarnings`] plus an `IrOverride`-sourced decision, +//! never a failure. A solve is [`SolveStatus::Solved`] only when every declared +//! constraint holds. //! //! Having earned it, [`Engraver::tier`] reports [`SolverTier::Minimal`] — which //! (Chapter 9 §"Conformance Tiers" / QUICKSTART) means *hard constraints -//! satisfied, no claim about optimality*. It therefore makes **no -//! normalized-metric claim**: the quality-metric vector stays the conservative -//! all-worst "no claim" placeholder ([`QualityMetricVector::unmeasured`]) until -//! the Quality Metric Catalog lands (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** (a single system, so it -//! cannot honour a forced system/page break). +//! satisfied, no claim about optimality* — greedy first-fit casting-off is +//! legitimate at this tier. It therefore makes **no normalized-metric claim**: +//! the quality-metric vector stays the conservative all-worst "no claim" +//! placeholder ([`QualityMetricVector::unmeasured`]) until the Quality Metric +//! Catalog lands (Phase 3 / `Standard`). Still deferred to a later tier: the +//! **vertical spring pass** (glyph `y` within a system is the constrained +//! natural staff layout, preserved verbatim; systems stack by real content +//! extents), per-system justification/stretch, and optimal break search. //! //! ## Architecture decision (see `DECISIONS.md`) //! @@ -44,18 +62,21 @@ //! //! [`epiphany-render-svg`]: ../epiphany_render_svg/index.html +pub mod casting; mod spacing; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use epiphany_layout_ir::{ all_available, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId, ConstraintSolver, ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId, InvalidationSet, - LayoutConstraint, Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, - ResolvedPage, ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, - SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke, + LayoutConstraint, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, + SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState, SolverTier, + SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke, }; +pub use casting::{PageGeometry, INTER_PAGE_GAP, SYSTEM_CONTINUATION_SYNTHESIS}; + /// The glyph a fixed-width stroke (a ledger line) belongs to: the same-source glyph /// whose baseline falls within the stroke's horizontal span (its accidentals sit /// outside the span, to the left). The stroke is anchored to this glyph's column so @@ -75,25 +96,45 @@ pub(crate) fn owning_glyph<'a>( } /// The Epiphany engraving solver (Chapter 9). A `Minimal`-tier solver: it spaces -/// glyphs horizontally and satisfies the IR's declared hard constraints. See the -/// crate docs for what each tier claims and what remains deferred. +/// glyphs horizontally, casts the result off into systems and pages against its +/// [`PageGeometry`], and satisfies the IR's declared hard constraints — break +/// constraints included. See the crate docs for what each tier claims and what +/// remains deferred. #[derive(Copy, Clone, Debug, Default)] -pub struct Engraver; +pub struct Engraver { + geometry: PageGeometry, +} /// The implementation version of this solver (Chapter 9: within a fixed version, -/// identical input produces identical output). Distinct from the stub's `0`. -pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(1); +/// identical input produces identical output). Distinct from the stub's `0`; +/// bumped to `2` when the casting-off pass landed (the resolved geometry of a +/// wrapping score differs from version `1`'s single endless system). +pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(2); impl Engraver { + /// An engraver casting off against the given page geometry. + /// [`Engraver::default`] uses [`PageGeometry::default`] (A4 portrait at an + /// 8 mm staff — see its docs for the arithmetic). + pub fn with_geometry(geometry: PageGeometry) -> Self { + Engraver { geometry } + } + + /// The page geometry this engraver casts off against. + pub fn geometry(&self) -> PageGeometry { + self.geometry + } + /// Resolves geometry: a deterministic horizontal spacing pass over the spring - /// slots (each glyph to its slot's `x`, baseline `y` preserved), then - /// evaluation of the declared constraints by strength. A malformed input — an - /// unknown glyph, a forged catalog identity, or invalid structure — yields - /// [`SolveStatus::InternalError`]; a valid problem whose `Required` - /// constraints cannot all be satisfied yields [`SolveStatus::Unsatisfiable`] - /// (naming the unsatisfied constraints). Both are diagnostic-only; neither - /// panics. Violated `Preferred` constraints yield soft-violation warnings - /// under [`SolveStatus::SolvedWithWarnings`] — a valid, renderable layout. + /// slots (each glyph to its slot's `x`, baseline `y` preserved), then the + /// casting-off pass (system breaking, vertical stacking, page assignment — + /// see [`casting`]), then evaluation of the declared constraints by + /// strength. A malformed input — an unknown glyph, a forged catalog + /// identity, or invalid structure — yields [`SolveStatus::InternalError`]; a + /// valid problem whose `Required` constraints cannot all be satisfied yields + /// [`SolveStatus::Unsatisfiable`] (naming the unsatisfied constraints). Both + /// are diagnostic-only; neither panics. Violated `Preferred` constraints + /// yield soft-violation warnings under [`SolveStatus::SolvedWithWarnings`] + /// — a valid, renderable layout. fn resolve(&self, input: &ConstrainedLayoutIR) -> SolveReport { let structural_valid = input.validate().is_ok(); @@ -109,23 +150,51 @@ impl Engraver { // stem behind. Both gate on structural validity: a malformed input must // not leak geometry into the diagnostic layout (which reaches // canonical_bytes / the renderer). - let (glyphs, strokes): (Vec, Vec) = if structural_valid { + let (spaced_glyphs, spaced_strokes): (Vec, Vec) = if structural_valid + { let remap = HorizontalRemap::build(input); (remap.glyphs(input), remap.strokes(input)) } else { (Vec::new(), Vec::new()) }; - let resolved_glyphs = glyphs.len(); + // Casting-off: break the spaced line into systems, stack them, assign + // pages, and bake every position into the single world frame. Pure + // geometry, so it runs whenever the structure is trustworthy (the + // catalog gate below only guards constraint *evaluation*). + let cast = if structural_valid { + Some(casting::cast_off( + input, + &spaced_glyphs, + &spaced_strokes, + &self.geometry, + )) + } else { + None + }; + let resolved_glyphs = spaced_glyphs.len(); - // Evaluate every declared constraint against the *resolved* geometry, - // routed by its strength (Chapter 9 §"Strength Levels"): a violated - // `Required` constraint is unsatisfied (the solve is `Unsatisfiable`); - // a violated `Preferred` one is a soft-violation *warning*, never a - // failure. A structurally invalid or bad-catalog input is not evaluated - // — there is no trustworthy geometry — so it reports no evaluation work. + // Evaluate every declared constraint, routed by its strength (Chapter 9 + // §"Strength Levels"): geometric constraints against the *pre-casting* + // spaced geometry (their frame of expression — casting-off relocates + // whole systems rigidly), break constraints against the final break + // structure. A violated `Required` constraint is unsatisfied (the solve + // is `Unsatisfiable`); a violated `Preferred` one is a soft-violation + // *warning*, never a failure. A structurally invalid or bad-catalog + // input is not evaluated — there is no trustworthy geometry — so it + // reports no evaluation work. let (evaluation, constraints_evaluated) = if structural_valid && catalog_valid { + let cast = cast + .as_ref() + .expect("casting ran on structurally valid input"); ( - evaluate_constraints(&input.constraints, &glyphs), + evaluate_constraints( + &input.constraints, + &spaced_glyphs, + &BreakOutcome { + system_starts: &cast.system_start_slots, + page_starts: &cast.page_start_slots, + }, + ), input.constraints.len() as u64, ) } else { @@ -157,9 +226,10 @@ impl Engraver { if structural_valid && catalog_valid && !unsatisfied_constraints.is_empty() { warnings.push(SolverWarning { kind: SolverWarningKind::UnusualLayoutDecision( - "one or more declared hard constraints are not satisfiable by this \ - single-system Minimal solve (e.g. a forced break, or an unverifiable \ - extension constraint); see unsatisfied_constraints" + "one or more declared hard constraints are not satisfied by this \ + Minimal solve (e.g. an unverifiable extension constraint, or \ + colliding geometry the spacing pass cannot separate); see \ + unsatisfied_constraints" .to_owned(), ), affected_objects: Vec::new(), @@ -167,28 +237,23 @@ impl Engraver { }); } - let pages = input - .regions - .first() - .map(|first| ResolvedPage { - provenance: first.provenance.clone(), - number: 1, - size: Size2D::default(), - margins: Margins::default(), - systems: input - .regions - .iter() - .map(|region| ResolvedSystem { - provenance: region.provenance.clone(), - bounding_box: Rect::default(), - staves: Vec::new(), - measures: Vec::new(), - }) - .collect(), - free_objects: Vec::new(), - }) - .into_iter() - .collect(); + // The final layout is the cast world frame: real pages and systems, + // glyph/stroke positions baked, the engraver's break decisions appended + // to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including + // any the solver itself made"). + let (glyphs, strokes, pages, engraving_decisions) = match cast { + Some(cast) => { + let mut decisions = input.engraving_decisions.clone(); + decisions.extend(cast.decisions); + (cast.glyphs, cast.strokes, cast.pages, decisions) + } + None => ( + Vec::new(), + Vec::new(), + Vec::new(), + input.engraving_decisions.clone(), + ), + }; SolveReport { status, @@ -198,7 +263,7 @@ impl Engraver { pages, glyphs, strokes, - engraving_decisions: input.engraving_decisions.clone(), + engraving_decisions, catalog: input.catalog.clone(), }, unsatisfied_constraints, @@ -209,7 +274,8 @@ impl Engraver { // conservative all-worst "no claim" placeholder, like the stub's. metric_vector: QualityMetricVector::unmeasured(), budget_used: SolverBudgetUsed { - // The horizontal pass touches each slot once; report that honestly. + // The horizontal pass and the casting-off walk each touch every + // slot once; report the spacing pass's touch honestly. iterations: input.horizontal_slots.len() as u64, nodes: resolved_glyphs as u64, constraint_evaluations: constraints_evaluated, @@ -353,23 +419,37 @@ impl ConstraintEvaluation { } } -/// Evaluates the IR's declared constraints against the *resolved* geometry — a -/// constraint's id is its index in the IR's constraint list — routing each -/// violation by [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"): -/// a violated `Required` constraint is reported unsatisfied, a violated -/// `Preferred` one becomes a [`SolverWarningKind::LargeSoftConstraintViolation`] -/// warning and never fails the solve. +/// The break structure the casting-off pass produced, for constraint +/// evaluation: the slots at which the final layout starts a system, and the +/// subset at which it starts a page. +struct BreakOutcome<'a> { + system_starts: &'a BTreeSet, + page_starts: &'a BTreeSet, +} + +/// Evaluates the IR's declared constraints — a constraint's id is its index in +/// the IR's constraint list — routing each violation by +/// [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"): a violated +/// `Required` constraint is reported unsatisfied, a violated `Preferred` one +/// becomes a [`SolverWarningKind::LargeSoftConstraintViolation`] warning and +/// never fails the solve. /// /// Geometric constraints (no-collision, alignment, position-within) are checked -/// against the resolved glyph boxes. A break is never *honoured* — a -/// single-system, single-page Minimal solve casts off nothing — so a hard break -/// (`Required`) is unsatisfied and a soft break (`Preferred`) is a warning. An -/// extension `Registered` constraint this solver cannot interpret is likewise -/// not claimed satisfied (Chapter 7 §"Behavior Under Unknown Extensions": +/// against the **pre-casting spaced** glyph boxes — the frame the constraints +/// are expressed in; casting-off then relocates whole systems by rigid motions +/// (see `DECISIONS.md`, frame of evaluation). Break constraints are checked +/// against the **cast break structure**: `SystemBreakAt`/`PageBreakAt` is +/// satisfied iff the final layout starts a system/page at that slot — so a hard +/// break is `Unsatisfiable` only if casting-off failed to honour it (which +/// cannot happen for a feasible, structurally valid input), and a soft break is +/// a warning exactly when it was skipped on the documented pathological path. +/// An extension `Registered` constraint this solver cannot interpret is not +/// claimed satisfied (Chapter 7 §"Behavior Under Unknown Extensions": /// conservative). fn evaluate_constraints( constraints: &[LayoutConstraint], glyphs: &[ResolvedGlyph], + breaks: &BreakOutcome, ) -> ConstraintEvaluation { let by_id: BTreeMap = glyphs .iter() @@ -392,7 +472,8 @@ fn evaluate_constraints( Some(g) => within(g, region), None => false, }, - LayoutConstraint::SystemBreakAt { .. } | LayoutConstraint::PageBreakAt { .. } => false, + LayoutConstraint::SystemBreakAt { slot, .. } => breaks.system_starts.contains(slot), + LayoutConstraint::PageBreakAt { slot, .. } => breaks.page_starts.contains(slot), LayoutConstraint::Registered(_, _) => false, }; if holds { @@ -409,8 +490,9 @@ fn evaluate_constraints( magnitude: 1.0, }, affected_objects: Vec::new(), - message: "a preferred (soft) constraint is not honoured by this \ - single-system Minimal solve" + message: "a preferred (soft) break is not honoured by this solve \ + (skipped on the pathological-system path; an IrOverride \ + decision records it)" .to_owned(), }), } @@ -508,14 +590,14 @@ mod tests { fn reports_the_minimal_tier_it_has_earned() { // It evaluates the declared hard constraints, so it reports Minimal — above // the interface-only stub, below the metric-claiming Standard tier. - assert_eq!(Engraver.tier(), SolverTier::Minimal); - assert!(Engraver.tier() > StubSolver.tier()); - assert!(Engraver.tier() < SolverTier::Standard); + assert_eq!(Engraver::default().tier(), SolverTier::Minimal); + assert!(Engraver::default().tier() > StubSolver.tier()); + assert!(Engraver::default().tier() < SolverTier::Standard); // Minimal makes no normalized-metric claim (the catalog is Phase 3). - let report = Engraver.solve(&fixture(), &SolverConfig::default()); + let report = Engraver::default().solve(&fixture(), &SolverConfig::default()); assert_eq!(report.metric_vector, QualityMetricVector::unmeasured()); - assert_eq!(Engraver.version(), ENGRAVER_VERSION); - assert_ne!(Engraver.version(), StubSolver.version()); + assert_eq!(Engraver::default().version(), ENGRAVER_VERSION); + assert_ne!(Engraver::default().version(), StubSolver.version()); } /// Builds a tiny valid constrained IR — a clef and a single note — and lets @@ -593,7 +675,7 @@ mod tests { !input.constraints.is_empty(), "the pipeline declares real constraints" ); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); assert_eq!(report.status, SolveStatus::Solved); assert!(report.satisfied_hard_constraints); assert!(report.unsatisfied_constraints.is_empty()); @@ -623,7 +705,7 @@ mod tests { .id(); vec![LayoutConstraint::NoCollision { a: clef, b: head }] }); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); assert!(report.satisfied_hard_constraints); assert!(report.unsatisfied_constraints.is_empty()); @@ -644,7 +726,7 @@ mod tests { .id(); vec![LayoutConstraint::NoCollision { a: g, b: g }] }); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); // A valid problem whose hard constraint cannot be met is Unsatisfiable, // not an InternalError (which is reserved for solver/structure failures). assert_eq!(report.status, SolveStatus::Unsatisfiable); @@ -653,33 +735,94 @@ mod tests { assert_eq!(report.budget_used.constraint_evaluations, 1); } + /// Total systems across all pages of a resolved layout. + fn system_count(layout: &ResolvedLayoutIR) -> usize { + layout.pages.iter().map(|p| p.systems.len()).sum() + } + #[test] - fn a_hard_break_cannot_be_honoured_by_single_system_minimal() { - use epiphany_layout_ir::{BreakKind, LayoutConstraint}; - // A hard break maps to ConstraintStrength::Required: a single-system - // solve cannot honour it, so the solve is Unsatisfiable. + fn a_hard_break_is_honoured_by_casting_off() { + use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind}; + // Inverse of the pre-casting-off pin (`a_hard_break_cannot_be_honoured_ + // by_single_system_minimal`): a hard break maps to + // ConstraintStrength::Required, and the casting-off pass ALWAYS breaks + // at it — even though that leaves a clef-only first system — so the + // solve is Solved and the system count increases. + let baseline = + Engraver::default().solve(&with_constraints(|_| vec![]), &SolverConfig::default()); + assert_eq!(baseline.status, SolveStatus::Solved); + assert_eq!(system_count(&baseline.layout), 1); + let input = with_constraints(|c| { - let slot = c.horizontal_slots[0].id; + // Slot 0 is the clef lead (trivially at a boundary); the note + // column is the non-trivial break target. + let slot = c.horizontal_slots[1].id; vec![LayoutConstraint::SystemBreakAt { slot, kind: BreakKind::Hard, }] }); - let report = Engraver.solve(&input, &SolverConfig::default()); - assert_eq!(report.status, SolveStatus::Unsatisfiable); - assert_eq!(report.unsatisfied_constraints.len(), 1); + let report = Engraver::default().solve(&input, &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + assert!(report.satisfied_hard_constraints); + assert!(report.unsatisfied_constraints.is_empty()); + assert_eq!( + system_count(&report.layout), + 2, + "the hard break splits the line into two systems" + ); + // The chosen break is recorded as an engraved decision; no user + // override projected this constraint, so it is attributed Automatic. + assert!(report + .layout + .engraving_decisions + .iter() + .any(|d| d.kind == EngravingDecisionKind::SystemBreak + && d.source == DecisionSource::Automatic)); + } - // …but a *soft* break is ConstraintStrength::Preferred: not honouring it - // is a soft-violation warning on a valid, renderable layout — a - // Preferred violation is a warning, never a failure. - let soft = with_constraints(|c| { - let slot = c.horizontal_slots[0].id; + #[test] + fn a_soft_break_with_content_before_it_is_honoured() { + use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind}; + // A soft break whose closing system carries musical content is simply + // honoured: a clean Solved two-system layout, no soft-violation + // warning, and an Automatic engraved decision. + let mut input = two_off_staff_whole_notes(); + let slot = input.horizontal_slots[2].id; // the second note column + input.constraints.push(LayoutConstraint::SystemBreakAt { + slot, + kind: BreakKind::Soft, + }); + let report = Engraver::default().solve(&input, &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + assert!(report.warnings.is_empty()); + assert!(report.satisfied_hard_constraints); + assert_eq!(system_count(&report.layout), 2); + assert!(report + .layout + .engraving_decisions + .iter() + .any(|d| d.kind == EngravingDecisionKind::SystemBreak + && d.source == DecisionSource::Automatic)); + } + + #[test] + fn a_pathological_soft_break_is_skipped_and_recorded_as_ir_override() { + use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind}; + // A soft break at the first note column would close a system containing + // only the clef — no musical content. The documented exceptional path + // skips it: still renderable (a Preferred violation is a warning, never + // a failure), the constraint is reported as a soft violation, and the + // unhonoured preference is recorded as an IrOverride-sourced decision + // (the spec's override-resolution rule: record, don't drop). + let input = with_constraints(|c| { + let slot = c.horizontal_slots[1].id; vec![LayoutConstraint::SystemBreakAt { slot, kind: BreakKind::Soft, }] }); - let report = Engraver.solve(&soft, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); assert_eq!(report.status, SolveStatus::SolvedWithWarnings); assert!(report.status.is_renderable()); assert!( @@ -694,25 +837,46 @@ mod tests { magnitude, } if magnitude == 1.0 ))); + assert_eq!( + system_count(&report.layout), + 1, + "the pathological break was skipped, not honoured" + ); + assert!(report + .layout + .engraving_decisions + .iter() + .any(|d| d.kind == EngravingDecisionKind::SystemBreak + && d.source == DecisionSource::IrOverride)); } #[test] - fn a_users_break_flows_to_a_soft_violation_not_a_failure() { - // End to end: a user system break on the score graph projects through - // the logical stage's break override into a Soft break constraint, - // which this single-system solve does not honour — surfacing as a - // Preferred-violation warning on a valid, renderable layout. + fn a_users_break_is_honoured_and_recorded_with_its_override() { + // Inverse of the pre-casting-off pin (`a_users_break_flows_to_a_soft_ + // violation_not_a_failure`). End to end: a user system break on the + // score graph projects through the logical stage's break override into + // a Soft break constraint, which casting-off HONOURS — the anchored + // column starts a new system at the left margin, the solve is clean + // (Solved, no warnings), and the engraved decision cites the user's + // override id (DecisionSource::UserOverride). use epiphany_core::generators::valid_score; - use epiphany_core::{AnchorOffset, Event, TimeAnchor}; + use epiphany_core::{AnchorOffset, Event, EventPosition, TimeAnchor}; + use epiphany_layout_ir::{DecisionSource, EngravingDecisionKind}; let mut score = valid_score(3); + // The latest pitched onset: a mid-region break target, so the closing + // system carries musical content (the honoured, non-pathological path). let event = score.canvas.regions[0] .staff_instances() .iter() .flat_map(|si| si.voices.iter()) .flat_map(|voice| voice.events.iter().copied()) - .find(|eid| { + .filter(|eid| { matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty()) }) + .max_by_key(|eid| match score.events.get(*eid).map(|e| e.position()) { + Some(EventPosition::Musical(p)) => Some(p.clone()), + _ => None, + }) .expect("valid_score has a pitched event"); score.canvas.regions[0] .content @@ -725,22 +889,55 @@ mod tests { }); let constrained = to_constrained(&to_logical(&score)); - assert!( - constrained - .constraints - .iter() - .any(|c| matches!(c, LayoutConstraint::SystemBreakAt { .. })), - "the user break projects into a break constraint" - ); - let report = Engraver.solve(&constrained, &SolverConfig::default()); - assert_eq!(report.status, SolveStatus::SolvedWithWarnings); - assert!(report.status.is_renderable()); + let break_slot = constrained + .constraints + .iter() + .find_map(|c| match c { + LayoutConstraint::SystemBreakAt { slot, .. } => Some(*slot), + _ => None, + }) + .expect("the user break projects into a break constraint"); + let origin = constrained + .break_origins + .iter() + .find(|o| o.slot == break_slot) + .expect("the projection records the override attribution"); + + let engraver = Engraver::default(); + let report = engraver.solve(&constrained, &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + assert!(report.warnings.is_empty(), "an honoured break never warns"); assert!(report.satisfied_hard_constraints); assert!(report.unsatisfied_constraints.is_empty()); - assert!(report.warnings.iter().any(|w| matches!( - w.kind, - SolverWarningKind::LargeSoftConstraintViolation { .. } - ))); + assert!( + system_count(&report.layout) >= 2, + "the honoured break increases the system count" + ); + // The break lands at the anchor's column: the anchored slot's glyphs + // now start their system at the page's left content edge (up to the + // ledger-line extension, 0.3 staff spaces, which also participates in + // the system's extent and may sit left of the notehead box). + let left_edge = report + .layout + .glyphs + .iter() + .zip(&constrained.glyphs) + .filter(|(_, c)| c.horizontal_slot == break_slot) + .map(|(r, c)| r.position.x.0 + c.bounding_box.left.0) + .fold(f32::INFINITY, f32::min); + let margin = engraver.geometry().margins.left.0; + assert!( + left_edge >= margin - 1e-3 && left_edge <= margin + 0.5, + "the anchored column starts its system at the left margin \ + (edge {left_edge}, margin {margin})" + ); + // The decision record cites the user's override. + assert!(report + .layout + .engraving_decisions + .iter() + .any(|d| d.kind == EngravingDecisionKind::SystemBreak + && d.source == DecisionSource::UserOverride(origin.override_id))); } #[test] @@ -760,7 +957,7 @@ mod tests { if widths.is_empty() { continue; } - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); for s in report .layout .strokes @@ -885,7 +1082,7 @@ mod tests { "off-staff whole notes earn ledger strokes" ); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); // The wide whole-note columns re-space (their deltas differ), so a midpoint- // anchored ledger would translate by a neighbouring column's delta and drift. // The owning-glyph anchor keeps every ledger at its notehead's offset. @@ -920,7 +1117,7 @@ mod tests { let mut checked = 0; for seed in 0..16 { let input = to_constrained(&to_logical(&valid_score_rich(seed))); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); for s_in in input.strokes.iter().filter(|s| is_rigid_width_stroke(s)) { let lo = s_in.from.x.0.min(s_in.to.x.0); let hi = s_in.from.x.0.max(s_in.to.x.0); @@ -968,7 +1165,7 @@ mod tests { let mut ledgers = 0; let mut pairs = 0; for seed in 0..32 { - let report = Engraver.solve( + let report = Engraver::default().solve( &to_constrained(&to_logical(&valid_score_rich(seed))), &SolverConfig::default(), ); @@ -1010,7 +1207,7 @@ mod tests { #[test] fn solves_the_stub_pipeline_and_preserves_provenance() { let input = fixture(); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); assert_eq!(report.status, SolveStatus::Solved); assert!(report.satisfied_hard_constraints); assert_eq!(report.layout.glyphs.len(), input.glyphs.len()); @@ -1049,7 +1246,7 @@ mod tests { input.validate().is_err(), "the out-of-range stroke is invalid" ); - let report = Engraver.solve(&input, &SolverConfig::default()); + let report = Engraver::default().solve(&input, &SolverConfig::default()); assert_eq!(report.status, SolveStatus::InternalError); assert!(report.layout.glyphs.is_empty()); assert!( @@ -1069,7 +1266,9 @@ mod tests { // from the stub's verbatim baselines. let input = fixture(); assert!(input.glyphs.len() >= 2); - let engraved = Engraver.solve(&input, &SolverConfig::default()).layout; + let engraved = Engraver::default() + .solve(&input, &SolverConfig::default()) + .layout; let stub = StubSolver.solve(&input, &SolverConfig::default()).layout; assert_ne!( engraved @@ -1092,7 +1291,9 @@ mod tests { // The spacing pass re-places glyphs; the strokes that track them must move // by the same horizontal map, not stay at their constrained coordinates. let input = fixture(); - let engraved = Engraver.solve(&input, &SolverConfig::default()).layout; + let engraved = Engraver::default() + .solve(&input, &SolverConfig::default()) + .layout; // Constrained x -> engraved x, per glyph. let glyph_map: Vec<(f32, f32)> = input @@ -1134,8 +1335,12 @@ mod tests { #[test] fn solve_is_deterministic_and_quantizable() { let input = fixture(); - let a = Engraver.solve(&input, &SolverConfig::default()).layout; - let b = Engraver.solve(&input, &SolverConfig::default()).layout; + let a = Engraver::default() + .solve(&input, &SolverConfig::default()) + .layout; + let b = Engraver::default() + .solve(&input, &SolverConfig::default()) + .layout; // Byte-identical canonical output across solves (Chapter 9 determinism). assert_eq!(a.canonical_bytes(), b.canonical_bytes()); } @@ -1210,7 +1415,7 @@ mod tests { }; let constrained = to_constrained(&logical); - let engraved = Engraver + let engraved = Engraver::default() .solve(&constrained, &SolverConfig::default()) .layout; let mut noteheads: Vec<_> = engraved @@ -1300,7 +1505,7 @@ mod tests { }; let constrained = to_constrained(&logical); - let engraved = Engraver + let engraved = Engraver::default() .solve(&constrained, &SolverConfig::default()) .layout; let x_of = |name: &str| { @@ -1342,8 +1547,8 @@ mod tests { #[test] fn incremental_is_observationally_equivalent_to_full() { let input = fixture(); - let full = Engraver.solve(&input, &SolverConfig::default()); - let inc = Engraver.solve_incremental( + let full = Engraver::default().solve(&input, &SolverConfig::default()); + let inc = Engraver::default().solve_incremental( &input, &full.state, &InvalidationSet { @@ -1386,7 +1591,7 @@ mod tests { // round_trip_with asserts the full provenance contract; a Solved // status also confirms the Engraver satisfied the pipeline's hard // constraints (the stub pipeline declares none, so vacuously). - let report = round_trip_with(&score, &Engraver); + let report = round_trip_with(&score, &Engraver::default()); assert_eq!(report.status, SolveStatus::Solved); } } @@ -1396,7 +1601,7 @@ mod tests { // survived a real geometry change rather than a pass-through. let constrained = to_constrained(&to_logical(&valid_score_rich(11))); assert!(constrained.glyphs.len() >= 2); - let engraved = Engraver + let engraved = Engraver::default() .solve(&constrained, &SolverConfig::default()) .layout; let stub = StubSolver @@ -1426,11 +1631,11 @@ mod tests { fn the_editing_loop_holds_through_the_real_engraver() { use epiphany_core::generators::valid_score_rich; for seed in 0..16u64 { - let report = - epiphany_testkit::editloop::run_edit_loop_with(&valid_score_rich(seed), &Engraver) - .unwrap_or_else(|| { - panic!("seed {seed}: no clickable notehead to drive the loop") - }); + let report = epiphany_testkit::editloop::run_edit_loop_with( + &valid_score_rich(seed), + &Engraver::default(), + ) + .unwrap_or_else(|| panic!("seed {seed}: no clickable notehead to drive the loop")); assert!(report.graph_changed, "seed {seed}: graph unchanged"); assert!( report.selection_preserved, @@ -1439,4 +1644,348 @@ mod tests { assert!(report.render_changed, "seed {seed}: edit not visible"); } } + + // ---- Casting-off (system breaking, stacking, page assignment) ---------- + + /// The QUICKSTART ten-measure hand-off fixture through the default page + /// geometry — the honest multi-system case the goldens lock. + fn ten_measure_constrained() -> ConstrainedLayoutIR { + to_constrained(&to_logical( + &epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE), + )) + } + + #[test] + fn greedy_wrap_breaks_at_measure_boundaries() { + let input = ten_measure_constrained(); + let engraver = Engraver::default(); + let report = engraver.solve(&input, &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + + let layout = &report.layout; + assert_eq!(layout.pages.len(), 1, "two short systems fit one page"); + let systems = &layout.pages[0].systems; + assert!( + systems.len() >= 2, + "the ten-measure fixture (≈99 staff spaces) wraps under the \ + default 90-staff-space content width; got {} system(s)", + systems.len() + ); + // Every wrapped system fits the content width (no measure in this + // fixture is wider than a page), and every system starts at the left + // content margin. + let geometry = engraver.geometry(); + for system in systems { + assert!( + system.bounding_box.size.width.0 <= geometry.content_width() + 1e-3, + "an automatically wrapped system must fit the content width" + ); + assert!( + (system.bounding_box.origin.x.0 - geometry.margins.left.0).abs() < 1e-3, + "every system starts at the left content margin" + ); + } + // The greedy pass breaks at measure boundaries only: each wrapped + // system after the first begins with a barline column. + for system in &systems[1..] { + let top = system.bounding_box.origin.y.0 + system.bounding_box.size.height.0; + let bottom = system.bounding_box.origin.y.0; + let first_glyph = layout + .glyphs + .iter() + .filter(|g| g.position.y.0 >= bottom - 1e-3 && g.position.y.0 <= top + 1e-3) + .min_by(|a, b| a.position.x.0.total_cmp(&b.position.x.0)) + .expect("a wrapped system has glyphs"); + assert!( + first_glyph.glyph.as_str().starts_with("barline"), + "a greedy system boundary sits at a measure boundary, got {}", + first_glyph.glyph.as_str() + ); + } + // One Automatic engraved decision per chosen boundary, *appended* to + // the pipeline's own decisions (which are carried through unchanged). + let appended = layout + .engraving_decisions + .iter() + .filter(|d| !input.engraving_decisions.contains(d)) + .collect::>(); + assert_eq!(appended.len(), systems.len() - 1); + assert!(appended.iter().all(|d| { + d.kind == epiphany_layout_ir::EngravingDecisionKind::SystemBreak + && d.source == epiphany_layout_ir::DecisionSource::Automatic + })); + } + + #[test] + fn a_hard_page_break_starts_a_new_page() { + use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind}; + let mut input = two_off_staff_whole_notes(); + let slot = input.horizontal_slots[2].id; // the second note column + input.constraints.push(LayoutConstraint::PageBreakAt { + slot, + kind: BreakKind::Hard, + }); + let engraver = Engraver::default(); + let report = engraver.solve(&input, &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + assert!(report.satisfied_hard_constraints); + assert_eq!( + report.layout.pages.len(), + 2, + "the hard page break paginates" + ); + assert_eq!(report.layout.pages[0].number, 1); + assert_eq!(report.layout.pages[1].number, 2); + assert_eq!(report.layout.pages[0].systems.len(), 1); + assert_eq!(report.layout.pages[1].systems.len(), 1); + // Page 2's system sits inside page 2's world frame (a full page height + // plus the inter-page gap below page 1's frame). + let geometry = engraver.geometry(); + let page2_content_top = + -(geometry.size.height.0 + crate::INTER_PAGE_GAP) - geometry.margins.top.0; + let system2 = &report.layout.pages[1].systems[0]; + let system2_top = system2.bounding_box.origin.y.0 + system2.bounding_box.size.height.0; + assert!( + (system2_top - page2_content_top).abs() < 1e-3, + "page 2's first system starts at page 2's content top \ + ({system2_top} vs {page2_content_top})" + ); + assert!(report + .layout + .engraving_decisions + .iter() + .any(|d| d.kind == EngravingDecisionKind::PageBreak + && d.source == DecisionSource::Automatic)); + } + + #[test] + fn vertical_stacking_respects_the_inter_system_gap() { + use epiphany_layout_ir::{VerticalBand, VerticalBandId}; + let report = + Engraver::default().solve(&ten_measure_constrained(), &SolverConfig::default()); + let systems = &report.layout.pages[0].systems; + assert!(systems.len() >= 2); + // The gap between consecutive systems' real extents is exactly the + // vertical-band model's preferred inter-system gap. + let preferred = VerticalBand::inter_system_gap(VerticalBandId(0)) + .preferred_height + .0; + for pair in systems.windows(2) { + let upper_bottom = pair[0].bounding_box.origin.y.0; + let lower_top = pair[1].bounding_box.origin.y.0 + pair[1].bounding_box.size.height.0; + let gap = upper_bottom - lower_top; + assert!( + (gap - preferred).abs() < 1e-3, + "inter-system gap {gap} != preferred {preferred}" + ); + } + } + + #[test] + fn page_overflow_starts_a_second_page() { + use epiphany_layout_ir::{Margins, Size2D, StaffSpace}; + // A deliberately small page: 50×10 staff spaces of content, so the + // ten-measure fixture wraps into systems (≈7 staff spaces tall) of + // which only one fits a page — the multi-page path. + let geometry = PageGeometry { + size: Size2D { + width: StaffSpace(60.0), + height: StaffSpace(20.0), + }, + margins: Margins { + top: StaffSpace(5.0), + right: StaffSpace(5.0), + bottom: StaffSpace(5.0), + left: StaffSpace(5.0), + }, + }; + let engraver = Engraver::with_geometry(geometry); + let report = engraver.solve(&ten_measure_constrained(), &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + let pages = &report.layout.pages; + assert!(pages.len() >= 2, "a 10-staff-space content page overflows"); + for (index, page) in pages.iter().enumerate() { + assert_eq!(page.number, index as u32 + 1, "page numbers are 1-based"); + assert!(!page.systems.is_empty(), "no page is emitted empty"); + assert_eq!(page.size, geometry.size); + assert_eq!(page.margins, geometry.margins); + // Every system lies within its page's content frame. + let page_top = -(index as f32) * (geometry.size.height.0 + crate::INTER_PAGE_GAP); + let content_top = page_top - geometry.margins.top.0; + let content_bottom = content_top - geometry.content_height(); + for system in &page.systems { + let top = system.bounding_box.origin.y.0 + system.bounding_box.size.height.0; + let bottom = system.bounding_box.origin.y.0; + assert!( + top <= content_top + 1e-3 && bottom >= content_bottom - 1e-3, + "system [{bottom}, {top}] escapes page {} content \ + [{content_bottom}, {content_top}]", + page.number + ); + } + } + } + + #[test] + fn the_resolved_page_tree_is_populated() { + use std::collections::BTreeSet; + let report = + Engraver::default().solve(&ten_measure_constrained(), &SolverConfig::default()); + let page = &report.layout.pages[0]; + assert_eq!(page.number, 1); + assert!(page.size.width.0 > 0.0 && page.size.height.0 > 0.0); + assert!(page.free_objects.is_empty()); + + let mut system_ids = BTreeSet::new(); + let mut measure_ids = BTreeSet::new(); + let mut measure_records = 0usize; + let mut previous_top = f32::INFINITY; + for system in &page.systems { + // Real, ordered bounding boxes: non-default, stacked top to bottom. + assert!(system.bounding_box.size.width.0 > 0.0); + assert!(system.bounding_box.size.height.0 > 0.0); + let top = system.bounding_box.origin.y.0 + system.bounding_box.size.height.0; + assert!(top < previous_top, "systems are ordered top to bottom"); + previous_top = top; + assert!( + system_ids.insert(system.provenance.stable_id), + "each system has a distinct stable id" + ); + // One staff record (the fixture is single-staff), spanning the + // system and standing four staff spaces tall (plus line thickness). + assert_eq!(system.staves.len(), 1); + let staff = &system.staves[0]; + let staff_height = staff.bounding_box.size.height.0; + assert!( + (4.0..4.5).contains(&staff_height), + "a five-line staff spans four staff spaces, got {staff_height}" + ); + assert!(staff.bounding_box.size.width.0 > 0.0); + // Measure records: within the system box, ordered by x, distinct. + let mut previous_x = f32::NEG_INFINITY; + for measure in &system.measures { + measure_records += 1; + assert!(measure_ids.insert(measure.measure), "measures are distinct"); + let x = measure.bounding_box.origin.x.0; + assert!(x > previous_x, "measures are ordered by x"); + previous_x = x; + assert!(measure.bounding_box.size.width.0 > 0.0); + assert!( + x >= system.bounding_box.origin.x.0 - 1e-3 + && x + measure.bounding_box.size.width.0 + <= system.bounding_box.origin.x.0 + + system.bounding_box.size.width.0 + + 1e-3, + "a measure record lies within its system" + ); + } + } + // Nine of the fixture's ten measures are marked by a start barline + // column (the final-barline measure's start is not marked by any + // column in this projection, so its record is honestly omitted). + assert_eq!(measure_records, 9); + } + + #[test] + fn casting_off_is_deterministic_byte_for_byte() { + // Chapter 9 determinism over the full multi-system output: two solves + // of the wrapping fixture produce byte-identical canonical layouts. + let input = ten_measure_constrained(); + let a = Engraver::default() + .solve(&input, &SolverConfig::default()) + .layout; + let b = Engraver::default() + .solve(&input, &SolverConfig::default()) + .layout; + assert_eq!(a.canonical_bytes(), b.canonical_bytes()); + } + + #[test] + fn staff_lines_are_split_per_system_with_synthesized_continuations() { + use epiphany_layout_ir::SynthesisKind; + let input = ten_measure_constrained(); + let report = Engraver::default().solve(&input, &SolverConfig::default()); + let systems = &report.layout.pages[0].systems; + assert!(systems.len() >= 2); + // Five lines of one staff, one segment per system: the first segment of + // each keeps the original stroke's provenance; each later one is + // synthesized under the continuation registry kind. + let continuations = report + .layout + .strokes + .iter() + .filter(|s| { + s.provenance.synthesis + == Some(SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS)) + }) + .count(); + assert_eq!( + continuations, + 5 * (systems.len() - 1), + "one synthesized continuation per staff line per later system" + ); + // Every input stroke's provenance survives (the first segments). + for stroke in &input.strokes { + assert!( + report + .layout + .strokes + .iter() + .any(|s| s.provenance == stroke.provenance), + "an input stroke's provenance was lost in the split" + ); + } + // Each system's staff-line segments stay within their system's box. + for system in systems { + let staff = &system.staves[0]; + let box_left = system.bounding_box.origin.x.0; + let box_right = box_left + system.bounding_box.size.width.0; + assert!(staff.bounding_box.origin.x.0 >= box_left - 1e-3); + assert!( + staff.bounding_box.origin.x.0 + staff.bounding_box.size.width.0 <= box_right + 1e-3 + ); + } + } + + #[test] + fn hit_testing_resolves_a_glyph_in_the_second_system() { + use epiphany_layout_ir::{to_render, HitShape, Point, PrimitiveRef}; + let input = ten_measure_constrained(); + let report = Engraver::default().solve(&input, &SolverConfig::default()); + let first_system_bottom = report.layout.pages[0].systems[0].bounding_box.origin.y.0; + // A real notehead that wrapped into a later system (below the first). + let (index, glyph) = report + .layout + .glyphs + .iter() + .enumerate() + .filter(|(_, g)| { + g.glyph.as_str().starts_with("notehead") && g.provenance.synthesis.is_none() + }) + .min_by(|a, b| a.1.position.y.0.total_cmp(&b.1.position.y.0)) + .expect("the fixture has noteheads"); + assert!( + glyph.position.y.0 < first_system_bottom, + "the lowest notehead sits below the first system (it wrapped)" + ); + // The baked world frame is the hit-test frame: clicking its box centre + // resolves to the same glyph and its score-graph source. + let render = to_render(&report.layout); + let map = render.hit_test_map(); + let region = map + .regions + .iter() + .find(|r| r.primitive == PrimitiveRef::Glyph(index)) + .expect("every glyph has a hit region"); + let HitShape::Box(bounds) = region.shape else { + panic!("a glyph hit region is a box"); + }; + let click = Point::new( + (bounds.left.0 + bounds.right.0) / 2.0, + (bounds.bottom.0 + bounds.top.0) / 2.0, + ); + let top = map.hit(click).into_iter().next().expect("the click hits"); + assert_eq!(top.layout_object, glyph.provenance.stable_id); + assert_eq!(top.source, glyph.provenance.source); + } } diff --git a/crates/epiphany-engrave/src/spacing.rs b/crates/epiphany-engrave/src/spacing.rs index 561219d..6b818ed 100644 --- a/crates/epiphany-engrave/src/spacing.rs +++ b/crates/epiphany-engrave/src/spacing.rs @@ -15,8 +15,10 @@ //! accidental zone). Reserving the next slot's left overhang against *this* slot's //! advance is what protects a note's accidental from overlapping the previous //! note — a single per-slot `preferred_width` could only reserve space to the -//! right of a slot's source. The vertical pass, the soft-spring stretch/compress -//! solve, and constraint evaluation remain `Minimal`-tier work (next phase). +//! right of a slot's source. The casting-off pass (`crate::casting`) then breaks +//! this spaced line into systems and pages; the vertical soft-spring +//! stretch/compress solve and per-system justification remain deferred (see +//! `DECISIONS.md`). use std::collections::BTreeMap; diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 7065de0..1afe0f1 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -349,6 +349,35 @@ object is covered); the provenance-preservation contract itself is unchanged. > `req:binfmt:condition-depth`), and adopts the open-value `ObjectKind` > decode stance (P12-E3, `req:binfmt:object-kind-open`). +- **Casting-off support surface (2026-07, the engrave casting-off slice).** + Three small, non-canonical additions made for `epiphany-engrave`'s + casting-off pass, kept here because they are IR-shape/contract concerns: + 1. **`ConstrainedLayoutIR.break_origins` (`BreakOrigin`)** — the spec's + `LayoutConstraint` enum is normative and carries no origin field, but a + casting-off solver that honours a user break must record the decision with + `DecisionSource::UserOverride(id)` (Chapter 7 §"Note Layout" / + §"Engraving Overrides"), and the override id would otherwise be lost at the + logical→constrained boundary. The projection therefore records the + attribution *alongside* the constraint list (slot, break class, override + id) rather than widening the normative enum. Non-canonical, like every + constrained-stage value. + 2. **`continuation_instance_key`** — the stable + `SynthesisInstanceKey` derivation for engraver-synthesized *continuations* + of an existing object (the per-system segments a casting-off break cuts a + region-spanning staff line into): keyed on the original object's stable id + plus the 1-based continuation ordinal, hashed under `MUSCLOID` domain + separation so segments of different lines cannot collide for one + `(source, kind)` pair. + 3. **Round-trip contract: solver-synthesized additions.** `round_trip_with` + previously asserted the constrained→resolved provenance maps *equal*; a + casting-off solver legitimately synthesizes new objects (staff-line + continuation segments), which Chapter 7 §"Provenance" explicitly allows + for engraver-synthesized objects. The contract is now: every constrained + object survives with its exact provenance (containment, not equality); + every solver addition must declare a `SynthesisKind` and derive from an + already-laid-out source (so the recovered source set is unchanged); the + `Stub` tier must add nothing. + ## Pass 12 candidates (ambiguities for the spec, not resolved in code) 1. **Strength attachment to constraint instances.** Chapter 9 §"Strength Levels" diff --git a/crates/epiphany-layout-ir/src/barrier.rs b/crates/epiphany-layout-ir/src/barrier.rs index d912993..64bf3f3 100644 --- a/crates/epiphany-layout-ir/src/barrier.rs +++ b/crates/epiphany-layout-ir/src/barrier.rs @@ -1100,16 +1100,17 @@ mod tests { tag: 7 }) ); - // Operation-kind tag 24 is one past the v1 vocabulary. + // Operation-kind tag 28 is one past the v1 vocabulary (the Phase-3 + // ops tranche appended 24..=27; encodings are append-only). let mut bytes = vec![0u8]; bytes.extend(set_blob(&[])); - bytes.extend(set_blob(&[vec![24u8]])); + bytes.extend(set_blob(&[vec![28u8]])); bytes.push(0); assert_eq!( EditBarrier::decode_canonical_bytes(&bytes), Err(BarrierDecodeError::InvalidTag { kind: "OperationKindTag", - tag: 24 + tag: 28 }) ); } diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index fae3f78..5f27c7d 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -106,6 +106,12 @@ pub struct ConstrainedLayoutIR { pub strokes: Vec, pub vertical_bands: Vec, pub constraints: Vec, + /// The user-override attributions behind the projected break constraints in + /// `constraints` (one entry per break constraint that originated in a user + /// break override), so a casting-off solver can cite the override id in the + /// decision it records. Engraver-independent constraints (tests, tools) have + /// no entry here and are attributed `DecisionSource::Automatic`. + pub break_origins: Vec, pub engraving_decisions: Vec, /// Engraving-coverage gaps surfaced rather than hidden: a pitch with no /// resolved spelling, a glyph the bundled metrics do not carry. Not a hard @@ -176,6 +182,29 @@ pub enum BreakKind { Soft, } +/// Which break-constraint family a [`BreakOrigin`] attributes: a +/// [`LayoutConstraint::SystemBreakAt`] or a [`LayoutConstraint::PageBreakAt`]. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum BreakClass { + System, + Page, +} + +/// The user-override origin of a projected break constraint (Chapter 7 +/// §"Engraving Overrides"): the spring slot the override's anchor realized to, +/// which break family it projected into, and the override id. The +/// [`LayoutConstraint`] enum is the spec's normative shape and carries no +/// origin, so the projection records the attribution alongside the constraint +/// list; a casting-off solver that honours the break cites this id in its +/// engraving-decision record (`DecisionSource::UserOverride`, Chapter 7 +/// §"Note Layout"). Non-canonical, like every constrained-stage value. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct BreakOrigin { + pub slot: SpringSlotId, + pub class: BreakClass, + pub override_id: crate::engraving::EngravingOverrideId, +} + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct ConstraintRegistryId(pub u128); @@ -497,14 +526,18 @@ const TIME_SIG_X: f32 = 0.5; // a time signature sits this far right of its barl const TIME_DIGIT_X: f32 = 0.8; // x advance per time-signature digit /// The horizontal half-reach of an emitted `PositionWithin` region, in staff -/// spaces. v0 performs no casting-off, so a region imposes no *horizontal* -/// bound on its glyphs — a conformant solver may re-space columns freely along -/// the open canvas. The containment obligation v0 can honestly state is the -/// **vertical** envelope (which the spacing pass computes from the very glyph -/// geometry the solvers preserve), so the emitted rect pins that envelope and -/// leaves the horizontal span at canvas scale: wide enough for any plausible -/// re-spacing, finite because the validator rejects non-finite constraint -/// regions. +/// spaces. The constrained stage performs no casting-off, so a region imposes +/// no *horizontal* bound on its glyphs — a conformant solver may re-space +/// columns freely along the open canvas. The containment obligation this stage +/// can honestly state is the **vertical** envelope (which the spacing pass +/// computes from the very glyph geometry it emits), so the emitted rect pins +/// that envelope and leaves the horizontal span at canvas scale: wide enough +/// for any plausible re-spacing, finite because the validator rejects +/// non-finite constraint regions. Geometric constraints are expressed — and +/// evaluated — in *this stage's frame*: a casting-off solver that relocates +/// whole systems (a per-system rigid motion) evaluates them against its +/// pre-casting spaced geometry, where the obligation is meaningful (see +/// `epiphany-engrave`). const POSITION_WITHIN_X_REACH: f32 = 1.0e6; /// The registry id for the engraver's **structural-line synthesis** (staff @@ -583,6 +616,7 @@ pub fn try_to_constrained( let mut vertical_bands = Vec::new(); let mut horizontal_slots = Vec::new(); let mut constraints = Vec::new(); + let mut break_origins = Vec::new(); let mut constrained_regions = Vec::new(); // Regions tile left-to-right; this advances by each region's width so all // coordinates stay globally monotonic (the solver's coordinate remap relies @@ -1348,6 +1382,17 @@ pub fn try_to_constrained( } else { LayoutConstraint::PageBreakAt { slot, kind } }); + // Record the attribution so the casting-off solver's decision can + // cite the user override that asked for this break. + break_origins.push(BreakOrigin { + slot, + class: if system { + BreakClass::System + } else { + BreakClass::Page + }, + override_id: override_record.id, + }); } // A staff band per manifested staff that carries glyphs, in first-glyph @@ -1398,6 +1443,7 @@ pub fn try_to_constrained( strokes, vertical_bands, constraints, + break_origins, engraving_decisions: logical.engraving_decisions.clone(), diagnostics, catalog, diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index 816b0e2..b9de1f6 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -92,10 +92,10 @@ pub use cache::{ ResolvedSystemCache, SystemId, }; pub use constrained::{ - active_clef, is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakKind, - ConstrainedLayoutIR, ConstrainedLayoutRegion, ConstrainedValidationError, ConstraintParameters, - ConstraintRegistryId, GlyphObject, GlyphObjectId, GlyphStyle, LayoutConstraint, - LayoutTransformError, SpringSlot, Stroke, + active_clef, is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakClass, + BreakKind, BreakOrigin, ConstrainedLayoutIR, ConstrainedLayoutRegion, + ConstrainedValidationError, ConstraintParameters, ConstraintRegistryId, GlyphObject, + GlyphObjectId, GlyphStyle, LayoutConstraint, LayoutTransformError, SpringSlot, Stroke, }; pub use engrave_theory::{ accidental_glyph, clef_glyph, flag_glyph, has_stem, key_signature, notehead_glyph, rest_glyph, @@ -123,8 +123,8 @@ pub use logical::{ VerticalExtent, }; pub use provenance::{ - manifestation_layout_id, stable_layout_id, synthesized_layout_id, LayoutObjectId, Provenance, - SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId, + continuation_instance_key, manifestation_layout_id, stable_layout_id, synthesized_layout_id, + LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId, }; pub use render::{ to_render, ColorConfiguration, ColorSpace, PassthroughRenderProducer, diff --git a/crates/epiphany-layout-ir/src/provenance.rs b/crates/epiphany-layout-ir/src/provenance.rs index 927a3a8..2b49211 100644 --- a/crates/epiphany-layout-ir/src/provenance.rs +++ b/crates/epiphany-layout-ir/src/provenance.rs @@ -194,11 +194,49 @@ pub fn synthesized_layout_id( LayoutObjectId(p.finish_trunc128()) } +/// Derives the [`SynthesisInstanceKey`] for an engraver-synthesized +/// **continuation** of an existing layout object — e.g. the segment of a +/// region-spanning staff line that a casting-off system break places in a later +/// system. Keyed on the original object's stable id (which already encodes its +/// full semantic identity — source, manifestation, line index) and the 1-based +/// continuation ordinal, hashed under the layout domain tag so keys from +/// different originals can never collide for one `(source, kind)` pair. The +/// ordinal names *which continuation of that object* the key identifies (its +/// second system's segment, its third's, …), which is the segment's semantic +/// identity — a casting-off artefact exists only relative to the break +/// structure that created it. +pub fn continuation_instance_key(original: LayoutObjectId, ordinal: u32) -> SynthesisInstanceKey { + let mut p = Preimage::new(DomainTag::LAYOUT_OBJECT_ID); + p.push_bytes(b"synthesis-instance/continuation"); + p.push_u64_le((original.0 >> 64) as u64); + p.push_u64_le(original.0 as u64); + p.push_u64_le(ordinal as u64); + SynthesisInstanceKey(p.finish_trunc128()) +} + #[cfg(test)] mod tests { use super::*; use epiphany_core::{EventId, RegionId}; + #[test] + fn continuation_keys_are_stable_and_distinct() { + let a = LayoutObjectId(0x1111); + let b = LayoutObjectId(0x2222); + assert_eq!( + continuation_instance_key(a, 1), + continuation_instance_key(a, 1) + ); + assert_ne!( + continuation_instance_key(a, 1), + continuation_instance_key(a, 2) + ); + assert_ne!( + continuation_instance_key(a, 1), + continuation_instance_key(b, 1) + ); + } + #[test] fn stable_id_is_a_pure_function_of_source() { let src = TypedObjectId::Event(EventId::from_raw(0x1234)); diff --git a/crates/epiphany-layout-ir/src/roundtrip.rs b/crates/epiphany-layout-ir/src/roundtrip.rs index 5c6d958..6a3ccf8 100644 --- a/crates/epiphany-layout-ir/src/roundtrip.rs +++ b/crates/epiphany-layout-ir/src/roundtrip.rs @@ -128,6 +128,12 @@ pub fn round_trip(score: &Score) -> RoundTripReport { /// and `stable_id` — must survive that re-spacing unchanged, and the recovered /// source set must still be exactly the set laid out. This is the strictly stronger /// statement: a solver may move geometry, never lose a provenance trace. +/// +/// A conformant solver may also **synthesize** additional objects of its own — a +/// casting-off pass splits a region-spanning staff line into per-system segments — +/// provided each addition declares a [`SynthesisKind`](crate::SynthesisKind) and +/// derives from a source that is already laid out (so the recovered source set is +/// unchanged); the stub passthrough must add nothing. pub fn round_trip_with(score: &Score, solver: &S) -> RoundTripReport { let logical = to_logical(score); let constrained = to_constrained(&logical); @@ -231,10 +237,39 @@ pub fn round_trip_with(score: &Score, solver: &S) -> RoundT .map(|g| &g.provenance) .chain(report.layout.strokes.iter().map(|s| &s.provenance)), ); - assert_eq!( - constrained_map, resolved_map, - "provenance not preserved constrained -> resolved" - ); + // Every constrained object survives into the resolved layout with its exact + // provenance. A conformant solver may additionally *synthesize* objects of + // its own — a casting-off pass splits a region-spanning staff line into + // per-system segments (Chapter 7 §"Provenance": engraver-synthesized + // objects declare a `SynthesisKind`) — so the resolved map may be a strict + // superset; the stub tier, a verbatim passthrough, must add nothing. + for (id, provenance) in &constrained_map { + assert_eq!( + resolved_map.get(id), + Some(provenance), + "constrained object {id:?} is not preserved (with its exact provenance) in resolved" + ); + } + let constrained_sources: BTreeSet = + constrained_map.values().map(|p| p.source).collect(); + for (id, provenance) in &resolved_map { + if constrained_map.contains_key(id) { + continue; + } + assert_ne!( + solver.tier(), + SolverTier::Stub, + "the stub passthrough must not add objects (added {id:?})" + ); + assert!( + provenance.synthesis.is_some(), + "solver-added object {id:?} must declare a synthesis kind" + ); + assert!( + constrained_sources.contains(&provenance.source), + "solver-added object {id:?} must derive from a laid-out source, not invent one" + ); + } let render = to_render(&report.layout); for (resolved_glyph, primitive) in report.layout.glyphs.iter().zip(&render.primitives) { diff --git a/crates/epiphany-layout-ir/src/solver.rs b/crates/epiphany-layout-ir/src/solver.rs index f6f74a4..51cd85c 100644 --- a/crates/epiphany-layout-ir/src/solver.rs +++ b/crates/epiphany-layout-ir/src/solver.rs @@ -607,6 +607,7 @@ mod tests { strokes: vec![], vertical_bands: vec![band], constraints: vec![], + break_origins: vec![], engraving_decisions: vec![], diagnostics: vec![], catalog, @@ -693,6 +694,7 @@ mod tests { strokes: vec![], vertical_bands: vec![band], constraints: vec![], + break_origins: vec![], engraving_decisions: vec![], diagnostics: vec![], catalog: GlyphCatalogIdentity::default(), diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index dfd0d15..c710cfe 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -783,3 +783,136 @@ equivocation fuzz plus the unchanged `run_equivocation_fuzz` gate this). ~87K env/s at 1K / 10K / 50K — the 50K row cleared its budget by ~8.7x, the gate printed its XPASS promotion notice, and the row was flipped to `Pass` in the same change (see `epiphany-testkit/DECISIONS.md` F1). + +## Phase-3 first tranche: staff/meter/tempo/layout ops + value-restoring undo (2026-07) + +The ratified catalog text (operation_catalog §CreateStaff, §"Meter and Tempo +Overwrites", §SetStaffLayout, and the **rewritten** §UndoTransaction) is the +contract for this tranche. Wire facts are strictly additive: +`OperationKind` discriminants `CreateStaff = 24`, `SetTimeSignature = 25`, +`SetTempoSegment = 26`, `SetStaffLayout = 27`; `OperationKindTag` +discriminants `InsertStaff = 24` (Create→Insert tag naming, cf. +`InsertRegion`), `SetTimeSignature = 25`, `SetTempoSegment = 26`, +`SetStaffLayout = 27`; `PreconditionFailureReason::TempoMapMalformed = 11`. +No other discriminant table changed. Decisions the catalog left to the +implementation: + +- **The differing-value re-create / re-carry precondition reason is + `TargetMissing`.** §CreateStaff makes a byte-identical re-create idempotent + (`AlreadyApplied`) and a differing value under a live id "a precondition + no-op", without naming a reason; the reducer reuses `TargetMissing` (the + generic dangling/unusable-target reason `ModifyCrossCutting`'s malformed + branch already uses) rather than minting a new discriminant. The same + discipline (and reason) applies to the `TimeSignature` value a + `SetTimeSignature` carries. A tombstoned id refuses as `TargetTombstoned`. +- **Tempo-map well-formedness is checked against the chain state, under the + coarse resolved-position key.** The resulting-map precondition + (`TempoMapMalformed`) evaluates the scope's prospective segment list built + from the tempo write chains (base-seeded under `reduce_onto`), ordered by + the same `resolved_anchor_position` key the LWW slot uses: carried-start / + key agreement, per-shape end data (a non-constant shape needs `end` + + `end_tempo`; a constant `end_tempo` must equal `start_tempo` — the graph + invariant checker's rules), resolved ends ≥ their start and ≤ the next + key. Removals cannot malform a map (the gap rule holds the prior tempo) and + always apply. +- **Graph normalization on removal.** A meter removal that empties a grid the + meter writes themselves created normalizes the region's + `default_metric_grid` back to `None` (unless a whole-grid write or the base + holds a grid independently); a tempo-segment removal that leaves a region's + local map empty and `initial`-less normalizes `local_tempo_map` to `None` + (an empty local map would *shadow* the score map rather than fall back to + it). Both keep "create on set" and "remove last" inverses of each other. +- **`CreateStaffInstance` now preconditions a live staff — graph-aware only.** + §CreateStaff makes the check normative now that staves are mintable; like + the insert preconditions, it is enforced when a graph is present (base-free + reduction has no staff universe), which keeps every existing seeded-base + scenario vacuously green. `CreateStaff`'s own instrument/group resolution + preconditions are graph-aware for the same reason. + +### Value-restoring undo: the write-chain design + +Every LWW overwrite family now maintains, per key, the **canonical-order write +chain** `WriteChain { base: Option, writes: Vec<(op, tx, value)> }` +(replacing the former `last_*` last-writer maps; each chain's last write is +still the LWW concurrent-differing comparison point). Families: event modify, +identified-pitch modify, respell, cross-cutting modify, metadata, metric grid, +meter change (new), tempo segment (new), staff layout (new), and the user +system/page breaks. The reducer applies operations in canonical order, so +appends are inherently canonical and every verdict below is +permutation-invariant by construction (pinned by +`undo_restoration_is_permutation_invariant` and the convergence gates). + +- **Seeding.** `seed_from_graph` seeds each chain's `base` with the base-graph + value (events, pitch values, cross-cutting values, metadata, grids, meter + changes, tempo segments, staff-instance layout fields, break anchors, and + explicit user-chosen per-pitch spelling attachments). Mints seed the chains + they create state for (`InsertEvent` → event + pitch chains, + `InsertIdentifiedPitch` → pitch chain, `CreateCrossCutting` → structure + chain, `CreateRegion` → grid/meter/break/tempo chains from the carried + content, `CreateStaffInstance` → layout chain), so a chain-predecessor is + defined identically under `reduce()` and `reduce_onto()` for op-minted + objects. Only base objects in base-free reduction lack seeds — the + established API-divergence caveat (see M2d) applies unchanged. +- **Undo verdict per written key** (`WriteChain::undo_verdict`): if the target + transaction's write is still the key's last, restore the chain-predecessor — + the latest non-member write, else the base value, else *absence*; if a later + writer superseded it, `StrictInverse` refuses the **whole** undo with a + `TransactionConflict` (`caused_by` = undo + the canonically-first + superseding writer) while `BestEffort` restores the still-last keys and + skips the superseded. Keys whose owning object is tombstoned — or is itself + one of the transaction's mints, about to be tombstoned by the same undo — + are skipped entirely (no live slot to restore; not a conflict). +- **Absence semantics per key.** Optional slots restore literal absence: the + grid clears, the meter change / tempo segment / break is removed (with the + normalizations above). The canonical bookkeeping maps (`spellings`, + `breaks`, `page_breaks`) return to **key-absence** whenever the predecessor + is the *base* value — base state lives in the graph, not the operational + ledger — while the graph restores the base value (spelling attachment, + break anchor). Always-valued families (event, pitch, cross-cutting, + metadata, staff layout) with no known predecessor (reachable only base-free, + for pre-horizon objects) restore nothing — a bookkeeping-only outcome. +- **Effects.** A fully clean compensation is `Applied`; `AppliedWithRepair` + carries **only** the minted-object tombstone repairs (`CascadeDeleted`), per + the catalog — restorations add no repair vocabulary. A transaction that + minted nothing and wrote nothing this reduction knows of stays the + `TargetMissing` no-op (the pre-tranche behavior); a transaction that *did* + write chains is now genuinely undoable — the sanctioned semantic change to + previously-TargetMissing overwrite-transaction undos. +- **Mixed transactions** compose both passes; `StrictInverse` refuses the + whole undo if *either* part fails (a tombstoned mint keeps the pre-existing + `TombstonedTarget` conflict; a superseded key raises the + `TransactionConflict` above), and the refusal applies nothing. Two new + strand guards protect the mint pass: a minted `Staff` still manifested by a + live non-member instance, and a minted `TimeSignature` still referenced by + a meter change that survives the restoration pass, refuse under + `StrictInverse` (a `TransactionConflict` naming the blocked object and its + referencer — reusing ratified conflict vocabulary rather than minting a new + kind; a dedicated "stranded reference" kind is a Pass-12 question) and are + skipped (left live) under `BestEffort`. +- **Undo-of-undo (pinned).** A restoration that restores a *value* enters the + key's chain as a new write by the undo operation. Consequences, both + pinned by tests: (i) undoing the first undo's own enclosing transaction + restores the value the first undo removed + (`undo_of_undo_restores_the_pre_undo_value`); (ii) a *second* undo of the + same target transaction finds the key superseded by the first undo — + `Conflicted` under `StrictInverse`, skipped under `BestEffort` + (`a_second_undo_of_the_same_transaction_sees_the_first_as_superseding`). + An *absence* restoration is not representable as a chain write and leaves + the chain unchanged, so repeating it is idempotent — the one asymmetry, + accepted until a chain-native absence entry is worth its weight. +- **Deferred residue** (unchanged from the catalog's "Still deferred"): + delete resurrection (P11-C8), `Transpose` inversion (P12-K2) — so a + transpose between a write and its undo is *not* re-applied on top of the + restored value (the chain predecessor wins; transpose composition is not a + chain write) — and `Cascade`'s dependent closure (`Cascade` remains + `StrictInverse` over the same set). + +Proposed Pass-12 rows from this tranche: (1) the `TargetMissing` reuse for +differing-value re-creates/re-carries (vs. a dedicated reason discriminant); +(2) a conflict-kind vocabulary for undo strand-blocks (live-reference +tombstone refusal) instead of `TransactionConflict` reuse; (3) whether an +undo's chain write should carry a distinguished provenance so a second undo +of the same transaction could be defined as idempotent rather than +conflicting; (4) P12-C5 stands as filed (the decomposition pre-pass still +honors only the first governing meter — the reduction semantics are pinned +here and tested under `a_mid_region_meter_change_reduces_cleanly_p12_c5`). diff --git a/crates/epiphany-ops/src/decode.rs b/crates/epiphany-ops/src/decode.rs index 0dd173b..fb5c701 100644 --- a/crates/epiphany-ops/src/decode.rs +++ b/crates/epiphany-ops/src/decode.rs @@ -220,6 +220,7 @@ fn precondition_reason(reader: &mut Reader<'_>) -> Result Ok(PreconditionFailureReason::ContainerNotEmpty), + 11 => Ok(PreconditionFailureReason::TempoMapMalformed), tag => Err(MaterializedDecodeError::InvalidTag { kind: "PreconditionFailureReason", tag, diff --git a/crates/epiphany-ops/src/effect.rs b/crates/epiphany-ops/src/effect.rs index aa73886..45cbf87 100644 --- a/crates/epiphany-ops/src/effect.rs +++ b/crates/epiphany-ops/src/effect.rs @@ -152,6 +152,11 @@ pub enum PreconditionFailureReason { /// has live children (an empty-only delete; the caller deletes contents /// first). ContainerNotEmpty, + /// A `SetTempoSegment` write whose *resulting* tempo map would be malformed + /// — segments out of order or overlapping, a non-constant shape missing its + /// end data, or a carried segment whose own start disagrees with the + /// operation's start key (operation_catalog §"Meter and Tempo Overwrites"). + TempoMapMalformed, /// An extension-declared precondition failed. ExtensionPrecondition(ExtensionPreconditionId), /// A registered precondition code from a versioned registry. @@ -173,6 +178,8 @@ impl PreconditionFailureReason { PreconditionFailureReason::Registered(_) => 9, // Additive (Group 3); keeps the ratified 0..=9 discriminants stable. PreconditionFailureReason::ContainerNotEmpty => 10, + // Additive (Phase-3 tranche, SetTempoSegment); appended past 10. + PreconditionFailureReason::TempoMapMalformed => 11, } } } diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 4b6d95a..62fc42b 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -30,12 +30,12 @@ use crate::causal::CausalContext; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue, - DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, - DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, + CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateStaffOp, CreateVoiceOp, + CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, + DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, - RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp, - TransposeOp, TupletCompensation, + RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, + SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; @@ -85,7 +85,7 @@ fn pitch(n: u64) -> PitchId { /// Generates a random payload over the shared id space. fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { - let kind = match rng.below(21) { + let kind = match rng.below(25) { 0 => { let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); @@ -196,7 +196,7 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { region: RegionId::new(ReplicaId(7), rng.below(3)), grid: rng.chance(2).then(valuegen::metric_grid), }), - _ => OperationKind::SetUserPageBreak(SetUserPageBreakOp { + 20 => OperationKind::SetUserPageBreak(SetUserPageBreakOp { region: RegionId::new(ReplicaId(7), 0), anchor: valuegen::region_start_anchor( RegionId::new(ReplicaId(7), 0), @@ -204,6 +204,57 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { ), present: rng.chance(2), }), + // Phase-3 first tranche: staff mint, meter/tempo overwrites, layout + // advisory — over the same shared id space so mints, re-carries, + // overwrites, and removals genuinely interact. + 21 => OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff( + StaffId::new(ReplicaId(7), rng.below(3)), + epiphany_core::InstrumentId::new(ReplicaId(7), rng.below(2)), + ), + }), + 22 => { + let region = RegionId::new(ReplicaId(7), rng.below(3)); + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: valuegen::region_start_anchor( + region, + MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 4)), + ), + time_signature: (!rng.chance(3)).then(|| { + valuegen::time_signature( + epiphany_core::TimeSignatureId::new(ReplicaId(7), rng.below(3)), + rng.below(3) as u16 + 2, + ) + }), + }) + } + 23 => { + let region = RegionId::new(ReplicaId(7), rng.below(3)); + let at = rng.below(3) as i32 * 4; + OperationKind::SetTempoSegment(SetTempoSegmentOp { + region: rng.chance(2).then_some(region), + start: valuegen::region_start_anchor( + region, + MusicalPosition(RationalTime::from_int(at)), + ), + segment: (!rng.chance(3)).then(|| { + valuegen::tempo_segment( + region, + MusicalPosition(RationalTime::from_int(at)), + 60.0 + rng.below(4) as f64 * 30.0, + ) + }), + }) + } + _ => OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), + instrument_override: None, + staff_lines_override: rng + .chance(2) + .then(epiphany_core::StaffLineConfiguration::default), + visible: rng.chance(2), + }), }; OperationPayload::Primitive(kind) } diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index f928f6f..23b341e 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -117,11 +117,12 @@ pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError}; pub use opset::{AcceptOutcome, OperationSet}; pub use payload::{ ChangeRegionTimeModelOp, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, - CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, - DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, - ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, - OperationPayload, PositionRemapping, ResolveConflictPayload, ResolveEquivocationPayload, - RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp, + CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, + DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, + InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, + OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload, + ResolveEquivocationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, + SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation, }; pub use reduce::{ diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index 687f69a..afd8d05 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -167,6 +167,11 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind { OperationKind::SetMetadata(op) => V0OperationKind::SetMetadata(op.clone()), OperationKind::SetMetricGrid(op) => V0OperationKind::SetMetricGrid(op.clone()), OperationKind::SetUserPageBreak(op) => V0OperationKind::SetUserPageBreak(op.clone()), + // v1-native (Phase-3 first tranche): projected verbatim. + OperationKind::CreateStaff(op) => V0OperationKind::CreateStaff(op.clone()), + OperationKind::SetTimeSignature(op) => V0OperationKind::SetTimeSignature(op.clone()), + OperationKind::SetTempoSegment(op) => V0OperationKind::SetTempoSegment(op.clone()), + OperationKind::SetStaffLayout(op) => V0OperationKind::SetStaffLayout(op.clone()), } } @@ -309,6 +314,11 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result OperationKind::SetMetadata(op.clone()), V0OperationKind::SetMetricGrid(op) => OperationKind::SetMetricGrid(op.clone()), V0OperationKind::SetUserPageBreak(op) => OperationKind::SetUserPageBreak(op.clone()), + // v1-native (Phase-3 first tranche): identity round-trip. + V0OperationKind::CreateStaff(op) => OperationKind::CreateStaff(op.clone()), + V0OperationKind::SetTimeSignature(op) => OperationKind::SetTimeSignature(op.clone()), + V0OperationKind::SetTempoSegment(op) => OperationKind::SetTempoSegment(op.clone()), + V0OperationKind::SetStaffLayout(op) => OperationKind::SetStaffLayout(op.clone()), }) } diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index ebd5603..3da7d60 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -33,9 +33,10 @@ use epiphany_core::{ Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch, - MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, - Region, RegionId, RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance, - StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId, + InstrumentId, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, + PitchSpelling, Region, RegionId, RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, Staff, + StaffId, StaffInstance, StaffInstanceId, StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, + TimeSignature, TransactionId, TupletId, TypedObjectId, Voice, VoiceId, }; use epiphany_determinism::{sorted_canonical, CanonicalDecode, CanonicalEncode, DecodeError}; @@ -154,6 +155,21 @@ pub enum OperationKind { /// Set a user page-break preference (LWW advisory; the page-break sibling of /// `SetUserSystemBreak`). SetUserPageBreak(SetUserPageBreakOp), + // --- Phase-3 first tranche: staff mint, meter/tempo overwrites, layout + // advisory (operation_catalog §CreateStaff, §"Meter and Tempo Overwrites", + // §SetStaffLayout). Discriminants extend additively past 23. --- + /// Mint a global staff on the score root (set-union creation). + CreateStaff(CreateStaffOp), + /// Set, replace, or remove the single meter change at a resolved position + /// in a region's default metric grid (LWW structural overwrite). + SetTimeSignature(SetTimeSignatureOp), + /// Set, replace, or remove the single tempo segment starting at a resolved + /// position in the score-level or region-local tempo map (LWW structural + /// overwrite). + SetTempoSegment(SetTempoSegmentOp), + /// Overwrite a staff instance's inline layout advisories as a unit (LWW + /// advisory). + SetStaffLayout(SetStaffLayoutOp), } impl OperationKind { @@ -183,6 +199,11 @@ impl OperationKind { OperationKind::SetMetadata(_) => 21, OperationKind::SetMetricGrid(_) => 22, OperationKind::SetUserPageBreak(_) => 23, + // Phase-3 first tranche; appended past the golden-locked 0..=23. + OperationKind::CreateStaff(_) => 24, + OperationKind::SetTimeSignature(_) => 25, + OperationKind::SetTempoSegment(_) => 26, + OperationKind::SetStaffLayout(_) => 27, } } @@ -215,6 +236,11 @@ impl OperationKind { OperationKind::SetMetadata(_) => OperationKindTag::SetMetadata, OperationKind::SetMetricGrid(_) => OperationKindTag::SetMetricGrid, OperationKind::SetUserPageBreak(_) => OperationKindTag::SetUserPageBreak, + // Create→Insert tag-naming convention, cf. `InsertRegion`. + OperationKind::CreateStaff(_) => OperationKindTag::InsertStaff, + OperationKind::SetTimeSignature(_) => OperationKindTag::SetTimeSignature, + OperationKind::SetTempoSegment(_) => OperationKindTag::SetTempoSegment, + OperationKind::SetStaffLayout(_) => OperationKindTag::SetStaffLayout, } } } @@ -250,6 +276,10 @@ impl CanonicalEncode for OperationKind { OperationKind::SetMetadata(op) => op.encode_canonical(out), OperationKind::SetMetricGrid(op) => op.encode_canonical(out), OperationKind::SetUserPageBreak(op) => op.encode_canonical(out), + OperationKind::CreateStaff(op) => op.encode_canonical(out), + OperationKind::SetTimeSignature(op) => op.encode_canonical(out), + OperationKind::SetTempoSegment(op) => op.encode_canonical(out), + OperationKind::SetStaffLayout(op) => op.encode_canonical(out), } } } @@ -284,6 +314,12 @@ pub enum OperationKindTag { DeleteVoice, SetMetadata, SetMetricGrid, + // Phase-3 first tranche. `InsertStaff` follows the tag layer's + // Create→Insert naming convention (cf. `InsertRegion` for `CreateRegion`). + InsertStaff, + SetTimeSignature, + SetTempoSegment, + SetStaffLayout, } impl OperationKindTag { @@ -313,6 +349,11 @@ impl OperationKindTag { OperationKindTag::DeleteVoice => 21, OperationKindTag::SetMetadata => 22, OperationKindTag::SetMetricGrid => 23, + // Phase-3 first tranche; appended past the golden-locked 0..=23. + OperationKindTag::InsertStaff => 24, + OperationKindTag::SetTimeSignature => 25, + OperationKindTag::SetTempoSegment => 26, + OperationKindTag::SetStaffLayout => 27, } } } @@ -378,6 +419,10 @@ impl CanonicalDecode for OperationKindTag { 21 => OperationKindTag::DeleteVoice, 22 => OperationKindTag::SetMetadata, 23 => OperationKindTag::SetMetricGrid, + 24 => OperationKindTag::InsertStaff, + 25 => OperationKindTag::SetTimeSignature, + 26 => OperationKindTag::SetTempoSegment, + 27 => OperationKindTag::SetStaffLayout, _ => return Err(DecodeError::MalformedDomainTag), }) } @@ -1132,6 +1177,150 @@ impl CanonicalEncode for SetUserPageBreakOp { } } +// --- Phase-3 first tranche (operation_catalog §CreateStaff, §"Meter and Tempo +// Overwrites", §SetStaffLayout). ---------------------------------------------- + +/// Mint a global [`Staff`] on the score root (operation_catalog §CreateStaff). +/// Carries the full global-staff value (v1): identity, name, abbreviation, +/// instrument reference, default staff-line configuration, and optional group +/// membership. Set-union creation, completing the structural-container family +/// upward: staff *instances* reference global staves. A repeat create carrying +/// a byte-identical value is idempotent; a differing value under a live id is a +/// precondition no-op. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CreateStaffOp { + pub staff: Staff, +} + +impl CreateStaffOp { + /// The minted staff's identifier. + pub fn staff_id(&self) -> StaffId { + self.staff.id + } +} + +impl CanonicalEncode for CreateStaffOp { + fn encode_canonical(&self, out: &mut Vec) { + push_lp_bytes(out, &self.staff.canonical_bytes()); + } +} + +/// Set, replace, or (`None`) remove the single meter change at the anchor's +/// resolved musical position in a region's default metric grid +/// (operation_catalog §"Meter and Tempo Overwrites"). Carries the full +/// [`TimeSignature`] value (v1), minted set-union under the same discipline as +/// `CreateStaff`. LWW structural overwrite keyed by `(region, resolved +/// position)`; concurrent differing writes collide on the field +/// `meter_sequence`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SetTimeSignatureOp { + pub region: RegionId, + pub anchor: TimeAnchor, + pub time_signature: Option, +} + +impl SetTimeSignatureOp { + /// The anchor's resolved musical position — the canonical LWW key (the + /// same coarse resolution the user-break advisories use). + pub fn resolved_position(&self) -> MusicalPosition { + resolved_anchor_position(&self.anchor) + } +} + +impl CanonicalEncode for SetTimeSignatureOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.region); + push_lp_bytes(out, &self.anchor.canonical_bytes()); + match &self.time_signature { + None => push_tag(out, 0), + Some(signature) => { + push_tag(out, 1); + push_lp_bytes(out, &signature.canonical_bytes()); + } + } + } +} + +/// Set, replace, or (`None`) remove the single tempo segment starting at the +/// resolved position, in the score-level tempo map (`region: None`) or the +/// region's local map (`Some`; a set on a region with no local map creates +/// one) (operation_catalog §"Meter and Tempo Overwrites"). LWW structural +/// overwrite keyed by `(scope, resolved start)`; a write that would malform +/// the resulting map is refused +/// ([`PreconditionFailureReason::TempoMapMalformed`](crate::PreconditionFailureReason)). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SetTempoSegmentOp { + pub region: Option, + pub start: TimeAnchor, + pub segment: Option, +} + +impl SetTempoSegmentOp { + /// The start anchor's resolved musical position — the canonical LWW key's + /// position half (the scope is the other half). + pub fn resolved_start(&self) -> MusicalPosition { + resolved_anchor_position(&self.start) + } +} + +impl CanonicalEncode for SetTempoSegmentOp { + fn encode_canonical(&self, out: &mut Vec) { + // Catalog: an Option discriminant and (when present) `region`, then the + // length-framed `start`, then an Option discriminant and (when present) + // the length-framed `segment`. + match &self.region { + None => push_tag(out, 0), + Some(region) => { + push_tag(out, 1); + push_canon(out, region); + } + } + push_lp_bytes(out, &self.start.canonical_bytes()); + match &self.segment { + None => push_tag(out, 0), + Some(segment) => { + push_tag(out, 1); + push_lp_bytes(out, &segment.canonical_bytes()); + } + } + } +} + +/// Overwrite a staff instance's inline layout advisories as a unit +/// (operation_catalog §SetStaffLayout): the three non-break layout advisories +/// with a graph home (`instrument_override`, `staff_lines_override`, +/// `visible`). LWW *advisory* keyed by `staff_instance` — no conflicts. The +/// richer engraving-override vocabulary has no durable graph home yet and +/// remains projected layout state. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SetStaffLayoutOp { + pub staff_instance: StaffInstanceId, + pub instrument_override: Option, + pub staff_lines_override: Option, + pub visible: bool, +} + +impl CanonicalEncode for SetStaffLayoutOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.staff_instance); + match &self.instrument_override { + None => push_tag(out, 0), + Some(instrument) => { + push_tag(out, 1); + push_canon(out, instrument); + } + } + match &self.staff_lines_override { + None => push_tag(out, 0), + Some(lines) => { + push_tag(out, 1); + push_lp_bytes(out, &lines.canonical_bytes()); + } + } + push_u8_bool(out, self.visible); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1142,9 +1331,9 @@ mod tests { // GOLDEN LOCK: the discriminant byte leads every canonically-encoded // primitive payload (operation_catalog §"Value-Typed Payloads"), so the // literal values are normative wire facts. Encodings are append-only: - // new kinds append past 23; the values below never change. + // new kinds append past 27; the values below never change. use crate::valuegen; - use epiphany_core::{MusicalDuration, MusicalPosition, StaffId}; + use epiphany_core::{MusicalDuration, MusicalPosition, TimeSignatureId}; let r = ReplicaId(1); let event_a = EventId::new(r, 1); @@ -1155,6 +1344,7 @@ mod tests { let instance = StaffInstanceId::new(r, 6); let voice = VoiceId::new(r, 7); let slur_id = SlurId::new(r, 8); + let instrument = InstrumentId::new(r, 10); let event_value = || { valuegen::insert_event_value( event_a, @@ -1167,7 +1357,7 @@ mod tests { let slur_value = || CrossCuttingValue::Slur(valuegen::slur(slur_id, event_a, event_b)); let anchor = || valuegen::region_start_anchor(region, MusicalPosition::origin()); - let table: [(OperationKind, u8); 24] = [ + let table: [(OperationKind, u8); 28] = [ ( OperationKind::InsertEvent(InsertEventOp { staff_instance: instance, @@ -1313,6 +1503,41 @@ mod tests { }), 23, ), + ( + OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff(staff, instrument), + }), + 24, + ), + ( + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: anchor(), + time_signature: Some(valuegen::time_signature(TimeSignatureId::new(r, 11), 4)), + }), + 25, + ), + ( + OperationKind::SetTempoSegment(SetTempoSegmentOp { + region: Some(region), + start: anchor(), + segment: Some(valuegen::tempo_segment( + region, + MusicalPosition::origin(), + 120.0, + )), + }), + 26, + ), + ( + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: instance, + instrument_override: Some(instrument), + staff_lines_override: None, + visible: true, + }), + 27, + ), ]; for (kind, expected) in &table { assert_eq!( @@ -1430,6 +1655,10 @@ mod tests { OperationKindTag::DeleteVoice, OperationKindTag::SetMetadata, OperationKindTag::SetMetricGrid, + OperationKindTag::InsertStaff, + OperationKindTag::SetTimeSignature, + OperationKindTag::SetTempoSegment, + OperationKindTag::SetStaffLayout, ]; let encoded: std::collections::BTreeSet<_> = tags .iter() @@ -1442,14 +1671,14 @@ mod tests { fn operation_kind_tag_decode_mirrors_encode_exactly() { // Every non-registered variant round-trips through its 1-byte form, and // the registered variant through its 17-byte (tag + registry id) form. - let mut tags: Vec = (0u8..24) + let mut tags: Vec = (0u8..28) .filter(|d| *d != 16) .map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant")) .collect(); tags.push(OperationKindTag::Registered(OperationKindRegistryId( 0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10, ))); - assert_eq!(tags.len(), 24, "the full v1 tag vocabulary"); + assert_eq!(tags.len(), 28, "the full v1 tag vocabulary"); for tag in tags { let bytes = tag.to_canonical_bytes(); let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips"); @@ -1465,10 +1694,10 @@ mod tests { #[test] fn operation_kind_tag_decode_rejects_malformed_bytes() { use epiphany_determinism::DecodeError; - // Unknown discriminant (24 is one past the v1 vocabulary): rejected, + // Unknown discriminant (28 is one past the v1 vocabulary): rejected, // never normalized. assert_eq!( - OperationKindTag::decode_canonical(&[24]), + OperationKindTag::decode_canonical(&[28]), Err(DecodeError::MalformedDomainTag) ); // Empty input. @@ -1480,6 +1709,21 @@ mod tests { assert!(OperationKindTag::decode_canonical(&[16; 19]).is_err()); } + #[test] + fn phase3_tag_discriminants_are_golden() { + // GOLDEN LOCK (Phase-3 first tranche): appended past the ratified + // 0..=23; the values below never change. + for (tag, expected) in [ + (OperationKindTag::InsertStaff, 24u8), + (OperationKindTag::SetTimeSignature, 25), + (OperationKindTag::SetTempoSegment, 26), + (OperationKindTag::SetStaffLayout, 27), + ] { + assert_eq!(tag.discriminant(), expected); + assert_eq!(tag.to_canonical_bytes(), vec![expected]); + } + } + #[test] fn reassign_remapping_is_order_independent() { let e1 = EventId::new(ReplicaId(1), 1); diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 9e7e8be..d479748 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -33,11 +33,13 @@ use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use epiphany_core::{ canonical_pitch_bytes, derive_promoted_voice_id, AnchorOffset, AnnotationAnchor, - CanonicalValue, Event, EventDuration, EventId, EventPosition, GestureAnchoring, MetricGrid, - MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, RationalTime, - RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, SpellingAttachment, SpellingDirective, - SpellingScope, SpellingSource, StaffId, StaffInstance, StaffInstanceId, TimeAnchor, - TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin, + CanonicalValue, Event, EventDuration, EventId, EventPosition, GestureAnchoring, InstrumentId, + MeterChange, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, + PitchSpelling, RationalTime, RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, + ScoreMetadata, SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource, Staff, + StaffId, StaffInstance, StaffInstanceId, StaffLineConfiguration, TempoMap, TempoSegment, + TempoShape, TimeAnchor, TimeSignature, TimeSignatureId, TransactionId, TypedObjectId, Voice, + VoiceId, VoiceOrigin, }; use epiphany_determinism::CanonicalEncode; @@ -53,11 +55,12 @@ use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool}; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue, - DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, - DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, - ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, - RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, TransposeOp, + resolved_anchor_position, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, + CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, + DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, + InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, + OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, + SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::StampTuple; @@ -618,6 +621,220 @@ pub fn reduce_operation_set_onto(op_set: &OperationSet, base: &Score) -> GraphMa } } +/// One write in a per-key canonical-order write chain (operation_catalog +/// §UndoTransaction, "Value restoration"): the writer, the transaction it was a +/// member of (if any), and the value it wrote. +#[derive(Clone, PartialEq, Eq, Debug)] +struct ChainWrite { + op: OperationId, + tx: Option, + value: V, +} + +/// The canonical-order write chain of one LWW-overwritten key (operation_catalog +/// §UndoTransaction). `base` is the key's pre-operational value — seeded from +/// the base graph (`seed_from_graph`) or from the value a mint carried — so a +/// chain-predecessor is defined for keys that exist before the first overwrite; +/// a key with no base entry restores to *absence*. `writes` append in canonical +/// processing order (the reducer applies operations in canonical order, so +/// appends are inherently canonical and the chain is permutation-invariant). +#[derive(Clone, PartialEq, Eq, Debug)] +struct WriteChain { + base: Option, + writes: Vec>, +} + +impl WriteChain { + fn new() -> Self { + WriteChain { + base: None, + writes: Vec::new(), + } + } + + /// Seeds the base value only if the chain has neither a base nor writes + /// (idempotent seeding; a mint never clobbers recorded history). + fn seed(&mut self, base: V) { + if self.base.is_none() && self.writes.is_empty() { + self.base = Some(base); + } + } + + /// Appends a write in canonical processing order. + fn record(&mut self, op: OperationId, tx: Option, value: V) { + self.writes.push(ChainWrite { op, tx, value }); + } + + /// The most recent write, if any (the LWW concurrency comparison point; + /// the base entry never participates in conflict detection). + fn last_write(&self) -> Option<&ChainWrite> { + self.writes.last() + } + + /// The key's current resolved value: the last write, falling back to the + /// base entry. + fn current(&self) -> Option<&V> { + self.last_write() + .map(|write| &write.value) + .or(self.base.as_ref()) + } + + /// The undo verdict for transaction `tx` on this key (operation_catalog + /// §UndoTransaction, "Value restoration"). + fn undo_verdict(&self, tx: TransactionId) -> ChainUndoVerdict { + if !self.writes.iter().any(|w| w.tx == Some(tx)) { + return ChainUndoVerdict::NotWritten; + } + let last = self.writes.last().expect("chain has a write by `tx`"); + if last.tx != Some(tx) { + return ChainUndoVerdict::Superseded { by: last.op }; + } + // Chain-predecessor: the latest entry not written by the transaction, + // falling back to the base value, else absence. + let predecessor = self + .writes + .iter() + .rev() + .find(|w| w.tx != Some(tx)) + .map(|w| Predecessor::Write(w.value.clone())) + .or_else(|| self.base.clone().map(Predecessor::Base)); + ChainUndoVerdict::Restore(predecessor) + } +} + +/// The chain-predecessor an undo restores: a prior operational write, or the +/// key's base (pre-operational) value. The distinction matters for the +/// canonical bookkeeping maps (`spellings`, `breaks`, `page_breaks`), which +/// return to key-*absence* when the predecessor is the base value — the base +/// state lives in the graph, not in the operational ledger. +#[derive(Clone, Debug)] +enum Predecessor { + Write(V), + Base(V), +} + +impl Predecessor { + fn into_value(self) -> V { + match self { + Predecessor::Write(v) | Predecessor::Base(v) => v, + } + } +} + +/// The per-key outcome of undoing a transaction's write chain. +enum ChainUndoVerdict { + /// The transaction never wrote this key. + NotWritten, + /// The transaction wrote the key but a later write superseded it. + Superseded { by: OperationId }, + /// The transaction's write is still last: restore the chain-predecessor + /// (`None` = the transaction introduced the first value; restore absence). + Restore(Option>), +} + +/// The staff-instance layout advisories `SetStaffLayout` overwrites as a unit +/// (operation_catalog §SetStaffLayout). +type StaffLayoutValue = (Option, Option, bool); + +/// One key restoration a value-restoring undo applies (operation_catalog +/// §UndoTransaction "Value restoration"). For the always-valued families +/// (event, pitch, cross-cutting, metadata, staff layout) `None` means the +/// chain knows no predecessor (an object minted before this reduction's +/// horizon, reachable only base-free): the undo verdict stands but there is no +/// value to write back — a bookkeeping-only restoration. For the optional +/// families (grid, meter change, tempo segment) the flattened `None` *is* the +/// restoration: clear/remove at the key. The canonical bookkeeping families +/// (spelling, breaks) keep the [`Predecessor`] distinction: a base predecessor +/// returns the ledger map to key-absence while the graph restores the base +/// value. +enum ValueRestoration { + Event { + event: EventId, + value: Option, + }, + Pitch { + pitch: PitchId, + value: Option, + }, + Spelling { + pitch: PitchId, + predecessor: Option>, + }, + CrossCutting { + id: TypedObjectId, + value: Option, + }, + Metadata { + value: Option, + }, + MetricGrid { + region: RegionId, + value: Option, + }, + MeterChange { + region: RegionId, + position: MusicalPosition, + value: Option, + }, + TempoSegment { + region: Option, + position: MusicalPosition, + value: Option, + }, + StaffLayout { + instance: StaffInstanceId, + value: Option, + }, + SystemBreak { + region: RegionId, + position: MusicalPosition, + predecessor: Option>, + }, + PageBreak { + region: RegionId, + position: MusicalPosition, + predecessor: Option>, + }, +} + +/// Whether a tempo segment's shape carries the end data it requires +/// (operation_catalog §"Meter and Tempo Overwrites"): a non-constant shape +/// needs both an explicit `end` and an `end_tempo`; a constant segment's +/// `end_tempo`, when present, must equal its `start_tempo` (the same structural +/// rules the graph-invariant checker applies to tempo maps). +fn tempo_segment_shape_well_formed(segment: &TempoSegment) -> bool { + match segment.shape { + TempoShape::Constant => segment + .end_tempo + .as_ref() + .map_or(true, |end| end == &segment.start_tempo), + TempoShape::Linear | TempoShape::Exponential | TempoShape::Curve => { + segment.end_tempo.is_some() && segment.end.is_some() + } + } +} + +/// Replaces (or removes, for `None`) the segment at `position` in `map`, +/// keeping the segment list ordered by resolved start position — the same +/// coarse anchor resolution the LWW key uses, so one resolved position holds +/// exactly one segment. +fn edit_tempo_map_segments( + map: &mut TempoMap, + position: &MusicalPosition, + segment: &Option, +) { + map.segments + .retain(|existing| resolved_anchor_position(&existing.start) != *position); + if let Some(segment) = segment { + let index = map + .segments + .iter() + .position(|existing| resolved_anchor_position(&existing.start) > *position) + .unwrap_or(map.segments.len()); + map.segments.insert(index, segment.clone()); + } +} + /// The working state of one reduction pass. struct Reducer<'a> { op_set: &'a OperationSet, @@ -633,20 +850,33 @@ struct Reducer<'a> { minted_by: BTreeMap, event_pitches: BTreeMap>, voice_occupancy: BTreeMap>, - last_respell: BTreeMap, - // LWW working state for the Group-1 field-overwrite ops: the last modifier - // and the value it wrote, used to detect concurrent *differing* modifications - // (the resolved value itself lives in the graph, not in MaterializedState). - last_event_modify: BTreeMap, - last_pitch_modify: BTreeMap, - // LWW working state for ModifyCrossCutting (Group 2), mirroring the leaf-field - // modify maps above: last modifier + value it wrote, keyed by structure id. - last_cross_cutting_modify: BTreeMap, - // LWW working state for SetMetricGrid (Group 4, M2d): the last writer and the - // grid it wrote, used to detect concurrent *differing* grids (the resolved - // value lives in the graph). SetMetadata is advisory LWW — no working state, - // no conflict — so the latest write in canonical order silently wins. - last_metric_grid: BTreeMap)>, + // Per-key canonical-order write chains for every LWW overwrite family + // (operation_catalog §UndoTransaction "Value restoration"). Each chain's + // last write doubles as the LWW working state the concurrent-differing + // conflict detection reads (formerly the `last_*` maps); the full chain is + // what value-restoring undo walks. Advisory families (metadata, breaks, + // staff layout) keep chains for undo but record no conflicts. + respell_chain: BTreeMap>, + event_modify_chain: BTreeMap>, + pitch_modify_chain: BTreeMap>, + cross_cutting_modify_chain: BTreeMap>, + metric_grid_chain: BTreeMap>>, + metadata_chain: WriteChain, + break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>, + page_break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>, + // Meter/tempo overwrite chains (Phase-3 tranche): `Some` = a set/replace at + // the key, `None` = an explicit removal. The meter value is the graph-level + // `MeterChange` (anchor + signature id) so a restoration can re-install it. + meter_change_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain>>, + tempo_segment_chain: + BTreeMap<(Option, MusicalPosition), WriteChain>>, + staff_layout_chain: BTreeMap>, + // Carried values of set-union-minted staves and time signatures, for the + // byte-identical-re-carry idempotence check (operation_catalog §CreateStaff: + // identical re-create is idempotent; a differing value under a live id is a + // precondition no-op). Seeded from the base graph. + staff_values: BTreeMap, + time_signature_values: BTreeMap, structures: BTreeMap>, // Live child sets for the structural-container empty-only delete (Group 3): // a region's live staff instances, and a staff instance's live voices. (A @@ -704,11 +934,20 @@ struct WorkingSnapshot { minted_by: BTreeMap, event_pitches: BTreeMap>, voice_occupancy: BTreeMap>, - last_respell: BTreeMap, - last_event_modify: BTreeMap, - last_pitch_modify: BTreeMap, - last_cross_cutting_modify: BTreeMap, - last_metric_grid: BTreeMap)>, + respell_chain: BTreeMap>, + event_modify_chain: BTreeMap>, + pitch_modify_chain: BTreeMap>, + cross_cutting_modify_chain: BTreeMap>, + metric_grid_chain: BTreeMap>>, + metadata_chain: WriteChain, + break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>, + page_break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>, + meter_change_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain>>, + tempo_segment_chain: + BTreeMap<(Option, MusicalPosition), WriteChain>>, + staff_layout_chain: BTreeMap>, + staff_values: BTreeMap, + time_signature_values: BTreeMap, structures: BTreeMap>, region_instances: BTreeMap>, instance_voices: BTreeMap>, @@ -950,11 +1189,19 @@ impl<'a> Reducer<'a> { minted_by: BTreeMap::new(), event_pitches: BTreeMap::new(), voice_occupancy: BTreeMap::new(), - last_respell: BTreeMap::new(), - last_event_modify: BTreeMap::new(), - last_pitch_modify: BTreeMap::new(), - last_cross_cutting_modify: BTreeMap::new(), - last_metric_grid: BTreeMap::new(), + respell_chain: BTreeMap::new(), + event_modify_chain: BTreeMap::new(), + pitch_modify_chain: BTreeMap::new(), + cross_cutting_modify_chain: BTreeMap::new(), + metric_grid_chain: BTreeMap::new(), + metadata_chain: WriteChain::new(), + break_chain: BTreeMap::new(), + page_break_chain: BTreeMap::new(), + meter_change_chain: BTreeMap::new(), + tempo_segment_chain: BTreeMap::new(), + staff_layout_chain: BTreeMap::new(), + staff_values: BTreeMap::new(), + time_signature_values: BTreeMap::new(), structures: BTreeMap::new(), region_instances: BTreeMap::new(), instance_voices: BTreeMap::new(), @@ -996,6 +1243,9 @@ impl<'a> Reducer<'a> { for staff in &score.staves { self.objects .insert(TypedObjectId::Staff(staff.id), ObjectState::Live); + // The carried value backs CreateStaff's byte-identical-re-carry + // idempotence check against base staves. + self.staff_values.insert(staff.id, staff.clone()); } for group in &score.staff_groups { self.objects @@ -1010,6 +1260,8 @@ impl<'a> Reducer<'a> { TypedObjectId::TimeSignature(signature.id), ObjectState::Live, ); + self.time_signature_values + .insert(signature.id, signature.clone()); } for layer in &score.analysis_layers { self.objects @@ -1020,11 +1272,54 @@ impl<'a> Reducer<'a> { .insert(TypedObjectId::View(view.id), ObjectState::Live); } + // The score-level LWW chains seed with the base values so a + // value-restoring undo of the first operational write can restore the + // pre-operational state (operation_catalog §UndoTransaction). + self.metadata_chain.seed(score.metadata.clone()); + for segment in &score.tempo_map.segments { + self.tempo_segment_chain + .entry((None, resolved_anchor_position(&segment.start))) + .or_insert_with(WriteChain::new) + .seed(Some(segment.clone())); + } + for region in &score.canvas.regions { self.objects .insert(TypedObjectId::Region(region.id), ObjectState::Live); - if region.content.staff_based().is_some() { + if let Some(content) = region.content.staff_based() { self.staff_based_regions.insert(region.id); + self.metric_grid_chain + .entry(region.id) + .or_insert_with(WriteChain::new) + .seed(content.default_metric_grid.clone()); + if let Some(grid) = &content.default_metric_grid { + for change in &grid.meter_sequence { + self.meter_change_chain + .entry((region.id, resolved_anchor_position(&change.anchor))) + .or_insert_with(WriteChain::new) + .seed(Some(change.clone())); + } + } + for anchor in &content.user_system_breaks { + self.break_chain + .entry((region.id, resolved_anchor_position(anchor))) + .or_insert_with(WriteChain::new) + .seed((anchor.clone(), true)); + } + for anchor in &content.user_page_breaks { + self.page_break_chain + .entry((region.id, resolved_anchor_position(anchor))) + .or_insert_with(WriteChain::new) + .seed((anchor.clone(), true)); + } + } + if let Some(local) = ®ion.local_tempo_map { + for segment in &local.segments { + self.tempo_segment_chain + .entry((Some(region.id), resolved_anchor_position(&segment.start))) + .or_insert_with(WriteChain::new) + .seed(Some(segment.clone())); + } } let instance_set = self.region_instances.entry(region.id).or_default(); for instance in region.staff_instances() { @@ -1034,6 +1329,14 @@ impl<'a> Reducer<'a> { self.objects .insert(TypedObjectId::StaffInstance(instance.id), ObjectState::Live); self.instance_staff.insert(instance.id, instance.staff); + self.staff_layout_chain + .entry(instance.id) + .or_insert_with(WriteChain::new) + .seed(( + instance.instrument_override, + instance.staff_lines_override.clone(), + instance.visible, + )); let voice_set = self.instance_voices.entry(instance.id).or_default(); for voice in &instance.voices { voice_set.insert(voice.id); @@ -1077,6 +1380,10 @@ impl<'a> Reducer<'a> { let event_id = event.id(); self.objects .insert(TypedObjectId::Event(event_id), ObjectState::Live); + self.event_modify_chain + .entry(event_id) + .or_insert_with(WriteChain::new) + .seed(event.clone()); let mut pitch_ids = Vec::new(); let mut pitches = Vec::new(); event.collect_identified_pitches(&mut pitches); @@ -1084,6 +1391,10 @@ impl<'a> Reducer<'a> { pitch_ids.push(pitch.id); self.objects .insert(TypedObjectId::Pitch(pitch.id), ObjectState::Live); + self.pitch_modify_chain + .entry(pitch.id) + .or_insert_with(WriteChain::new) + .seed(pitch.pitch.clone()); // Register base synthetic pitches in the mint registry (same // rule as promoted voices above). if pitch.id.replica() == ReplicaId::SYSTEM_DERIVED { @@ -1126,6 +1437,10 @@ impl<'a> Reducer<'a> { for slur in &score.cross_cutting.slurs { let id = TypedObjectId::Slur(slur.id); self.objects.insert(id, ObjectState::Live); + self.cross_cutting_modify_chain + .entry(id) + .or_insert_with(WriteChain::new) + .seed(CrossCuttingValue::Slur(slur.clone())); self.structures.insert( id, vec![ @@ -1137,6 +1452,10 @@ impl<'a> Reducer<'a> { for tie in &score.cross_cutting.ties { let id = TypedObjectId::Tie(tie.id); self.objects.insert(id, ObjectState::Live); + self.cross_cutting_modify_chain + .entry(id) + .or_insert_with(WriteChain::new) + .seed(CrossCuttingValue::Tie(tie.clone())); self.structures.insert( id, vec![ @@ -1148,6 +1467,10 @@ impl<'a> Reducer<'a> { for beam in &score.cross_cutting.beams { let id = TypedObjectId::Beam(beam.id); self.objects.insert(id, ObjectState::Live); + self.cross_cutting_modify_chain + .entry(id) + .or_insert_with(WriteChain::new) + .seed(CrossCuttingValue::Beam(beam.clone())); self.structures.insert( id, beam.events @@ -1173,6 +1496,10 @@ impl<'a> Reducer<'a> { for spanner in &score.cross_cutting.spanners { let id = TypedObjectId::Spanner(spanner.id); self.objects.insert(id, ObjectState::Live); + self.cross_cutting_modify_chain + .entry(id) + .or_insert_with(WriteChain::new) + .seed(CrossCuttingValue::Spanner(spanner.clone())); // Record the spanner's event-anchored endpoints so a later event // tombstone re-anchors it through the same rule table as a created // spanner (keeping the graph and ledger consistent on delete). @@ -1241,6 +1568,24 @@ impl<'a> Reducer<'a> { self.objects .insert(TypedObjectId::ChordSymbol(chord.id), ObjectState::Live); } + // The base score's explicit user-chosen per-pitch spellings seed the + // respell chains, so undoing the first operational respell restores + // the base attachment value rather than dropping it (the bookkeeping + // `spellings` map still returns to key-absence — base state lives in + // the graph, not the operational ledger). + for attachment in &score.spelling_attachments { + if attachment.layer.is_none() && matches!(attachment.source, SpellingSource::UserChosen) + { + if let (SpellingScope::Pitch(pitch), SpellingDirective::Explicit(spelling)) = + (&attachment.scope, &attachment.directive) + { + self.respell_chain + .entry(*pitch) + .or_insert_with(WriteChain::new) + .seed(spelling.clone()); + } + } + } } fn run(mut self) -> (MaterializedState, Option) { @@ -2017,6 +2362,14 @@ impl<'a> Reducer<'a> { TypedObjectId::Beam(id) => { score.cross_cutting.beams.retain(|value| value.id != *id); } + // Phase-3 mints: a tombstoned staff / time signature leaves the + // graph (the undo path preconditions no live reference remains). + TypedObjectId::Staff(id) => { + score.staves.retain(|value| value.id != *id); + } + TypedObjectId::TimeSignature(id) => { + score.time_signatures.retain(|value| value.id != *id); + } _ => {} } } @@ -2059,7 +2412,7 @@ impl<'a> Reducer<'a> { OperationKind::RespellPitch(op) => self.respell_pitch(env, op), OperationKind::CreateCrossCutting(op) => self.create_cross_cutting(env, op), OperationKind::ChangeRegionTimeModel(op) => self.change_region_time_model(env, op), - OperationKind::SetUserSystemBreak(op) => self.set_user_system_break(op), + OperationKind::SetUserSystemBreak(op) => self.set_user_system_break(env, op), OperationKind::DeclareTransaction(desc) => { self.descriptors.insert(desc.id, env.id); OperationEffect::Applied @@ -2080,9 +2433,13 @@ impl<'a> Reducer<'a> { OperationKind::DeleteStaffInstance(op) => self.delete_staff_instance(env, op), OperationKind::CreateVoice(op) => self.create_voice(env, op), OperationKind::DeleteVoice(op) => self.delete_voice(env, op), - OperationKind::SetMetadata(op) => self.set_metadata(op), + OperationKind::SetMetadata(op) => self.set_metadata(env, op), OperationKind::SetMetricGrid(op) => self.set_metric_grid(env, op), - OperationKind::SetUserPageBreak(op) => self.set_user_page_break(op), + OperationKind::SetUserPageBreak(op) => self.set_user_page_break(env, op), + OperationKind::CreateStaff(op) => self.create_staff(env, op), + OperationKind::SetTimeSignature(op) => self.set_time_signature(env, op), + OperationKind::SetTempoSegment(op) => self.set_tempo_segment(env, op), + OperationKind::SetStaffLayout(op) => self.set_staff_layout(env, op), }, OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op), OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op), @@ -2094,6 +2451,7 @@ impl<'a> Reducer<'a> { fn set_user_system_break( &mut self, + env: &OperationEnvelope, op: &crate::payload::SetUserSystemBreakOp, ) -> OperationEffect { if let Some(effect) = self.layout_region_slot(op.region) { @@ -2108,8 +2466,12 @@ impl<'a> Reducer<'a> { } // The LWW bucketing key is the anchor's resolved musical position. - self.breaks - .insert((op.region, op.resolved_position()), op.present); + let key = (op.region, op.resolved_position()); + self.breaks.insert(key.clone(), op.present); + self.break_chain + .entry(key) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, (op.anchor.clone(), op.present)); OperationEffect::Applied } @@ -2146,9 +2508,12 @@ impl<'a> Reducer<'a> { } } - fn set_metadata(&mut self, op: &SetMetadataOp) -> OperationEffect { + fn set_metadata(&mut self, env: &OperationEnvelope, op: &SetMetadataOp) -> OperationEffect { // Advisory LWW: no conflict, no idempotence short-circuit. The resolved - // value is the last write in canonical order, held in the graph. + // value is the last write in canonical order, held in the graph. The + // write chain backs value-restoring undo only. + self.metadata_chain + .record(env.id, env.transaction, op.metadata.clone()); if let Some(score) = self.graph.as_mut() { score.metadata = op.metadata.clone(); } @@ -2184,9 +2549,10 @@ impl<'a> Reducer<'a> { } } let prev = self - .last_metric_grid + .metric_grid_chain .get(&op.region) - .map(|(o, g)| (*o, g.clone())); + .and_then(|chain| chain.last_write()) + .map(|write| (write.op, write.value.clone())); let effect = match prev { Some((prev_op, prev_grid)) if self.concurrent(env.id, prev_op) => { if prev_grid == op.grid { @@ -2209,8 +2575,10 @@ impl<'a> Reducer<'a> { } _ => OperationEffect::Applied, }; - self.last_metric_grid - .insert(op.region, (env.id, op.grid.clone())); + self.metric_grid_chain + .entry(op.region) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, op.grid.clone()); self.graph_set_metric_grid(op.region, &op.grid); effect } @@ -2226,7 +2594,11 @@ impl<'a> Reducer<'a> { } } - fn set_user_page_break(&mut self, op: &SetUserPageBreakOp) -> OperationEffect { + fn set_user_page_break( + &mut self, + env: &OperationEnvelope, + op: &SetUserPageBreakOp, + ) -> OperationEffect { if let Some(effect) = self.layout_region_slot(op.region) { return effect; } @@ -2237,8 +2609,12 @@ impl<'a> Reducer<'a> { } } } - self.page_breaks - .insert((op.region, op.resolved_position()), op.present); + let key = (op.region, op.resolved_position()); + self.page_breaks.insert(key.clone(), op.present); + self.page_break_chain + .entry(key) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, (op.anchor.clone(), op.present)); OperationEffect::Applied } @@ -2359,6 +2735,21 @@ impl<'a> Reducer<'a> { self.objects.insert(ev_obj, ObjectState::Live); self.minted_by.insert(ev_obj, env.id); self.note_minted(env, ev_obj); + // Seed the write chains with the minted values (the same value the + // graph materializes), so a later modify's chain-predecessor is the + // inserted state in graph-free and graph-aware reduction alike. + self.event_modify_chain + .entry(event_id) + .or_insert_with(WriteChain::new) + .seed(graph_event_from_insert(op, target_voice)); + let mut carried: Vec<&epiphany_core::IdentifiedPitch> = Vec::new(); + op.event.collect_identified_pitches(&mut carried); + for ip in &carried { + self.pitch_modify_chain + .entry(ip.id) + .or_insert_with(WriteChain::new) + .seed(ip.pitch.clone()); + } let mut pitches = Vec::new(); for p in op.pitch_ids() { let p_obj = TypedObjectId::Pitch(p); @@ -2544,7 +2935,12 @@ impl<'a> Reducer<'a> { Some(ObjectState::Live) => {} } - match self.last_respell.get(&op.pitch).copied() { + let prev_op = self + .respell_chain + .get(&op.pitch) + .and_then(|chain| chain.last_write()) + .map(|write| write.op); + match prev_op { None => { self.materialize_respell(env, op); OperationEffect::Applied @@ -2595,7 +2991,10 @@ impl<'a> Reducer<'a> { /// in `MaterializedState.spellings` and lost before annotation derivation. fn materialize_respell(&mut self, env: &OperationEnvelope, op: &RespellPitchOp) { self.spellings.insert(op.pitch, op.spelling.clone()); - self.last_respell.insert(op.pitch, env.id); + self.respell_chain + .entry(op.pitch) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, op.spelling.clone()); self.graph_respell_pitch(op.pitch, &op.spelling); } @@ -2661,6 +3060,12 @@ impl<'a> Reducer<'a> { self.objects.insert(sid, ObjectState::Live); self.minted_by.insert(sid, env.id); self.note_minted(env, sid); + // Seed the write chain with the minted value, so a later modify's + // chain-predecessor is the created state. + self.cross_cutting_modify_chain + .entry(sid) + .or_insert_with(WriteChain::new) + .seed(op.structure.clone()); self.structures.insert(sid, endpoints); OperationEffect::Applied } @@ -2710,9 +3115,11 @@ impl<'a> Reducer<'a> { }, ); // Drop the transient endpoint/LWW indices so a later event tombstone's - // re-anchoring pass never re-processes the deleted structure. + // re-anchoring pass never re-processes the deleted structure. The write + // chain goes with it: a delete is not inverted (P11-C8), so a + // tombstoned structure's chain can never be restored. self.structures.remove(&sid); - self.last_cross_cutting_modify.remove(&sid); + self.cross_cutting_modify_chain.remove(&sid); self.graph_delete_cross_cutting(sid); OperationEffect::Applied } @@ -2763,9 +3170,10 @@ impl<'a> Reducer<'a> { // the graph; MaterializedState records only the effect and, on a // concurrent differing write, a StructuralFieldCollision. let prev = self - .last_cross_cutting_modify + .cross_cutting_modify_chain .get(&sid) - .map(|(o, v)| (*o, v.clone())); + .and_then(|chain| chain.last_write()) + .map(|write| (write.op, write.value.clone())); let effect = match prev { Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => { if prev_value == op.structure { @@ -2788,8 +3196,10 @@ impl<'a> Reducer<'a> { } _ => OperationEffect::Applied, }; - self.last_cross_cutting_modify - .insert(sid, (env.id, op.structure.clone())); + self.cross_cutting_modify_chain + .entry(sid) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, op.structure.clone()); self.structures.insert(sid, endpoints); self.graph_modify_cross_cutting(&op.structure); effect @@ -2905,8 +3315,47 @@ impl<'a> Reducer<'a> { self.graph_create_region(&op.region); self.mint_container(env, robj); self.region_instances.entry(op.region_id()).or_default(); - if op.region.content.staff_based().is_some() { + if let Some(content) = op.region.content.staff_based() { self.staff_based_regions.insert(op.region_id()); + // Seed the region's layout/metric write chains from the carried + // content (empty of typed children, but it may carry a grid or + // break advisories), so a later overwrite's chain-predecessor is + // the created state. + self.metric_grid_chain + .entry(op.region_id()) + .or_insert_with(WriteChain::new) + .seed(content.default_metric_grid.clone()); + if let Some(grid) = &content.default_metric_grid { + for change in &grid.meter_sequence { + self.meter_change_chain + .entry((op.region_id(), resolved_anchor_position(&change.anchor))) + .or_insert_with(WriteChain::new) + .seed(Some(change.clone())); + } + } + for anchor in &content.user_system_breaks { + self.break_chain + .entry((op.region_id(), resolved_anchor_position(anchor))) + .or_insert_with(WriteChain::new) + .seed((anchor.clone(), true)); + } + for anchor in &content.user_page_breaks { + self.page_break_chain + .entry((op.region_id(), resolved_anchor_position(anchor))) + .or_insert_with(WriteChain::new) + .seed((anchor.clone(), true)); + } + } + if let Some(local) = &op.region.local_tempo_map { + for segment in &local.segments { + self.tempo_segment_chain + .entry(( + Some(op.region_id()), + resolved_anchor_position(&segment.start), + )) + .or_insert_with(WriteChain::new) + .seed(Some(segment.clone())); + } } OperationEffect::Applied } @@ -2935,6 +3384,23 @@ impl<'a> Reducer<'a> { if !op.instance.voices.is_empty() || !op.instance.measures.is_empty() { return container_not_empty(); } + // With staves mintable (operation_catalog §CreateStaff), the instance's + // referenced global Staff must be live — the mint must leave the graph + // satisfying reference resolution. Graph-aware only (like the insert + // preconditions): base-free reduction has no staff universe to check + // against, and the base-seeded scenarios satisfy this vacuously. + if self.graph.is_some() + && !matches!( + self.objects.get(&TypedObjectId::Staff(op.instance.staff)), + Some(ObjectState::Live) + ) + { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } self.graph_create_staff_instance(op.region, &op.instance); self.mint_container(env, iobj); self.region_instances @@ -2944,6 +3410,16 @@ impl<'a> Reducer<'a> { self.instance_voices.entry(op.instance_id()).or_default(); self.instance_staff .insert(op.instance_id(), op.instance.staff); + // Seed the layout-advisory chain with the minted instance's fields, so + // a later SetStaffLayout's chain-predecessor is the created state. + self.staff_layout_chain + .entry(op.instance_id()) + .or_insert_with(WriteChain::new) + .seed(( + op.instance.instrument_override, + op.instance.staff_lines_override.clone(), + op.instance.visible, + )); OperationEffect::Applied } @@ -2975,6 +3451,457 @@ impl<'a> Reducer<'a> { OperationEffect::Applied } + // --- Phase-3 first tranche (operation_catalog §CreateStaff, §"Meter and + // Tempo Overwrites", §SetStaffLayout). ------------------------------------ + + /// Set-union creation of a global `Staff` on the score root + /// (operation_catalog §CreateStaff): fresh id mints; a byte-identical + /// re-carry is idempotent; a differing value under a live id is a + /// precondition no-op. Graph-aware reduction additionally preconditions + /// that the referenced instrument is live and, when `group` is present, + /// that the staff group resolves. + fn create_staff(&mut self, env: &OperationEnvelope, op: &CreateStaffOp) -> OperationEffect { + let sobj = TypedObjectId::Staff(op.staff_id()); + match self.objects.get(&sobj) { + Some(ObjectState::Live) => { + let identical = self + .staff_values + .get(&op.staff_id()) + .is_some_and(|known| known == &op.staff); + return if identical { + OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + } + } else { + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + }; + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + None => {} + } + // Reference-resolution preconditions are graph-aware (like the insert + // preconditions): base-free reduction has no instrument/group universe + // to check against. + if self.graph.is_some() { + if !matches!( + self.objects + .get(&TypedObjectId::Instrument(op.staff.instrument)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + if let Some(group) = op.staff.group { + if !matches!( + self.objects.get(&TypedObjectId::StaffGroup(group)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + } + } + if let Some(score) = self.graph.as_mut() { + score.staves.push(op.staff.clone()); + } + self.mint_container(env, sobj); + self.staff_values.insert(op.staff_id(), op.staff.clone()); + OperationEffect::Applied + } + + /// Set-union mint of a `TimeSignature` carried by a `SetTimeSignature` + /// (operation_catalog §"Meter and Tempo Overwrites"): fresh id mints; + /// byte-identical re-carry is idempotent; a differing value under a live + /// id — or a tombstoned id — refuses the whole operation. + fn mint_time_signature( + &mut self, + env: &OperationEnvelope, + signature: &TimeSignature, + ) -> Result<(), OperationEffect> { + let obj = TypedObjectId::TimeSignature(signature.id); + match self.objects.get(&obj) { + Some(ObjectState::Live) => { + let identical = self + .time_signature_values + .get(&signature.id) + .is_some_and(|known| known == signature); + if identical { + Ok(()) + } else { + Err(OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }) + } + } + Some(ObjectState::Tombstoned { .. }) => Err(OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + }), + None => { + self.mint_container(env, obj); + self.time_signature_values + .insert(signature.id, signature.clone()); + if let Some(score) = self.graph.as_mut() { + if !score.time_signatures.iter().any(|t| t.id == signature.id) { + score.time_signatures.push(signature.clone()); + } + } + Ok(()) + } + } + } + + /// Sets, replaces, or removes the single meter change at the anchor's + /// resolved position in the region's default metric grid — an LWW + /// structural overwrite keyed by `(region, resolved position)` + /// (operation_catalog §"Meter and Tempo Overwrites"). The carried + /// signature's beat-group sum is validated at construction and at decode, + /// so a malformed value never reaches this reduction. + fn set_time_signature( + &mut self, + env: &OperationEnvelope, + op: &SetTimeSignatureOp, + ) -> OperationEffect { + if let Some(effect) = self.layout_region_slot(op.region) { + return effect; + } + if let Some(signature) = &op.time_signature { + if let Err(effect) = self.mint_time_signature(env, signature) { + return effect; + } + } + let key = (op.region, op.resolved_position()); + let written: Option = + op.time_signature.as_ref().map(|signature| MeterChange { + anchor: op.anchor.clone(), + time_signature: signature.id, + }); + let prev = self + .meter_change_chain + .get(&key) + .and_then(|chain| chain.last_write()) + .map(|write| (write.op, write.value.clone())); + let effect = match prev { + Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => { + if prev_value == written { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }; + } + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: env.id, + loser: prev_op, + field: FieldPath("meter_sequence".to_string()), + }, + vec![env.id, prev_op], + vec![TypedObjectId::Region(op.region)], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + _ => OperationEffect::Applied, + }; + self.meter_change_chain + .entry(key.clone()) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, written.clone()); + self.graph_apply_meter_change(op.region, &key.1, &written); + effect + } + + /// Applies a meter-change overwrite (or removal) to the region's default + /// metric grid, keeping the sequence ordered by resolved position. A set + /// on a region whose grid is `None` creates the grid; a removal that + /// empties the sequence normalizes the slot back to `None` unless a + /// whole-grid write (or the base) holds a grid value independently. + fn graph_apply_meter_change( + &mut self, + region: RegionId, + position: &MusicalPosition, + change: &Option, + ) { + let baseline_grid = self + .metric_grid_chain + .get(®ion) + .and_then(|chain| chain.current()) + .is_some_and(|grid| grid.is_some()); + let Some(score) = self.graph.as_mut() else { + return; + }; + let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) else { + return; + }; + let Some(content) = region.content.staff_based_mut() else { + return; + }; + match change { + Some(meter) => { + let grid = content + .default_metric_grid + .get_or_insert_with(MetricGrid::default); + grid.meter_sequence + .retain(|existing| resolved_anchor_position(&existing.anchor) != *position); + let index = grid + .meter_sequence + .iter() + .position(|existing| resolved_anchor_position(&existing.anchor) > *position) + .unwrap_or(grid.meter_sequence.len()); + grid.meter_sequence.insert(index, meter.clone()); + } + None => { + if let Some(grid) = content.default_metric_grid.as_mut() { + grid.meter_sequence + .retain(|existing| resolved_anchor_position(&existing.anchor) != *position); + if grid.meter_sequence.is_empty() && !baseline_grid { + content.default_metric_grid = None; + } + } + } + } + } + + /// Whether the scope's *resulting* tempo map is well-formed with `written` + /// installed at `key` (operation_catalog §"Meter and Tempo Overwrites"): + /// the carried segment's own start equals the operation's key, every + /// segment's shape carries its end data, and resolved ends neither precede + /// their own start nor overlap the next segment. Read purely from the + /// tempo chains' current values (which seed from the base map), so + /// graph-free and graph-aware reduction agree wherever both represent the + /// scope. + fn prospective_tempo_write_well_formed( + &self, + key: &(Option, MusicalPosition), + written: &TempoSegment, + ) -> bool { + if resolved_anchor_position(&written.start) != key.1 { + return false; + } + let mut segments: Vec<(MusicalPosition, &TempoSegment)> = Vec::new(); + for ((scope, position), chain) in &self.tempo_segment_chain { + if scope != &key.0 || *position == key.1 { + continue; + } + if let Some(Some(segment)) = chain.current() { + segments.push((position.clone(), segment)); + } + } + segments.push((key.1.clone(), written)); + segments.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (index, (position, segment)) in segments.iter().enumerate() { + if !tempo_segment_shape_well_formed(segment) { + return false; + } + if let Some(end) = &segment.end { + let resolved_end = resolved_anchor_position(end); + if resolved_end < *position { + return false; + } + if let Some((next_position, _)) = segments.get(index + 1) { + if resolved_end > *next_position { + return false; + } + } + } + } + true + } + + /// Sets, replaces, or removes the single tempo segment starting at the + /// resolved position in the scoped tempo map — an LWW structural overwrite + /// keyed by `(scope, resolved start)` (operation_catalog §"Meter and Tempo + /// Overwrites"). A write that would malform the resulting map is refused + /// (`TempoMapMalformed`). + fn set_tempo_segment( + &mut self, + env: &OperationEnvelope, + op: &SetTempoSegmentOp, + ) -> OperationEffect { + if let Some(region) = op.region { + if !matches!( + self.objects.get(&TypedObjectId::Region(region)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + } + let key = (op.region, op.resolved_start()); + if let Some(segment) = &op.segment { + if !self.prospective_tempo_write_well_formed(&key, segment) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TempoMapMalformed, + }, + }; + } + } + let prev = self + .tempo_segment_chain + .get(&key) + .and_then(|chain| chain.last_write()) + .map(|write| (write.op, write.value.clone())); + let effect = match prev { + Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => { + if prev_value == op.segment { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }; + } + let affected = match op.region { + Some(region) => vec![TypedObjectId::Region(region)], + None => vec![], + }; + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: env.id, + loser: prev_op, + field: FieldPath("tempo_segments".to_string()), + }, + vec![env.id, prev_op], + affected, + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + _ => OperationEffect::Applied, + }; + self.tempo_segment_chain + .entry(key.clone()) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, op.segment.clone()); + self.graph_apply_tempo_segment(op.region, &key.1, &op.segment); + effect + } + + /// Applies a tempo-segment overwrite (or removal) to the scoped map. A set + /// on a region with no local map creates one; an empty, `initial`-less + /// local map left behind by a removal normalizes back to `None` (an empty + /// local map would shadow the score map instead of falling back to it). + fn graph_apply_tempo_segment( + &mut self, + scope: Option, + position: &MusicalPosition, + segment: &Option, + ) { + let Some(score) = self.graph.as_mut() else { + return; + }; + match scope { + None => edit_tempo_map_segments(&mut score.tempo_map, position, segment), + Some(region_id) => { + let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region_id) + else { + return; + }; + if segment.is_some() && region.local_tempo_map.is_none() { + region.local_tempo_map = Some(TempoMap::default()); + } + if let Some(map) = region.local_tempo_map.as_mut() { + edit_tempo_map_segments(map, position, segment); + } + if region + .local_tempo_map + .as_ref() + .is_some_and(|map| map.initial.is_none() && map.segments.is_empty()) + { + region.local_tempo_map = None; + } + } + } + } + + /// Overwrites a staff instance's three inline layout advisories as a unit + /// — an LWW *advisory* keyed by `staff_instance` (operation_catalog + /// §SetStaffLayout): no conflicts; the latest write in canonical order + /// wins. A present `instrument_override` must resolve to a live instrument + /// under graph-aware reduction. + fn set_staff_layout( + &mut self, + env: &OperationEnvelope, + op: &SetStaffLayoutOp, + ) -> OperationEffect { + match self + .objects + .get(&TypedObjectId::StaffInstance(op.staff_instance)) + { + None => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + Some(ObjectState::Live) => {} + } + if self.graph.is_some() { + if let Some(instrument) = op.instrument_override { + if !matches!( + self.objects.get(&TypedObjectId::Instrument(instrument)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + } + } + let value: StaffLayoutValue = ( + op.instrument_override, + op.staff_lines_override.clone(), + op.visible, + ); + self.staff_layout_chain + .entry(op.staff_instance) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value.clone()); + self.graph_set_staff_layout(op.staff_instance, &value); + OperationEffect::Applied + } + + fn graph_set_staff_layout(&mut self, instance_id: StaffInstanceId, value: &StaffLayoutValue) { + let Some(score) = self.graph.as_mut() else { + return; + }; + for region in &mut score.canvas.regions { + if let Some(instances) = region.content.staff_instances_mut() { + if let Some(instance) = instances.iter_mut().find(|i| i.id == instance_id) { + instance.instrument_override = value.0; + instance.staff_lines_override = value.1.clone(); + instance.visible = value.2; + return; + } + } + } + } + /// `Some(effect)` when `obj` cannot be deleted (missing, idempotent /// re-delete, or non-empty); `None` with the resolved minter when the /// empty-only delete may proceed. @@ -3430,49 +4357,39 @@ impl<'a> Reducer<'a> { } } + /// Forward compensating undo (operation_catalog §UndoTransaction): the + /// minted-object tombstoning pass plus, per this revision, the + /// value-restoration pass over every LWW write chain the target + /// transaction wrote. `StrictInverse` refuses the whole undo if *either* + /// part fails; `BestEffort` compensates what it cleanly can. A fully + /// clean compensation is `Applied`; `AppliedWithRepair` carries only the + /// tombstone repairs from minted objects. fn undo_transaction( &mut self, env: &OperationEnvelope, op: &UndoTransactionPayload, ) -> OperationEffect { let targets = self.tx_minted.get(&op.target).cloned().unwrap_or_default(); - if targets.is_empty() { + let (restorations, superseded) = self.collect_restorations(op.target, &targets); + if targets.is_empty() && restorations.is_empty() && superseded.is_empty() { + // The transaction minted nothing and overwrote nothing this + // reduction knows of (unknown, rolled back, or all its written + // keys are gone): nothing to compensate. return OperationEffect::NoOp { reason: NoOpReason::PreconditionFailedUnderReduction { reason: PreconditionFailureReason::TargetMissing, }, }; } - let all_live = targets - .iter() - .all(|t| matches!(self.objects.get(t), Some(ObjectState::Live))); match op.policy { UndoPolicy::StrictInverse | UndoPolicy::Cascade => { - if all_live { - let mut repairs = Vec::new(); - for t in &targets { - let minter = self.minted_by.get(t).copied().unwrap_or(env.id); - self.objects.insert( - *t, - ObjectState::Tombstoned { - deleted_by: env.id, - minted_by: minter, - }, - ); - repairs.push(RepairRecord { - kind: RepairKind::CascadeDeleted, - target: *t, - }); - } - repairs.extend(self.materialize_graph_tombstones(env, &targets)); - OperationEffect::AppliedWithRepair { repairs } - } else { - // A target was already tombstoned/modified: strict undo conflicts. - let stuck = targets - .iter() - .find(|t| !matches!(self.objects.get(t), Some(ObjectState::Live))) - .copied() - .unwrap_or(targets[0]); + // A minted target already tombstoned: strict undo conflicts + // (the pre-existing minted-object discipline). + if let Some(stuck) = targets + .iter() + .find(|t| !matches!(self.objects.get(t), Some(ObjectState::Live))) + .copied() + { let conflict = ConflictRecord::new( ConflictKind::TombstonedTarget { target: stuck, @@ -3483,35 +4400,582 @@ impl<'a> Reducer<'a> { ); let cid = conflict.id; self.conflicts.insert(conflict); - OperationEffect::Conflicted { conflict: cid } + return OperationEffect::Conflicted { conflict: cid }; + } + // A minted staff still manifested by a live instance, or a + // minted time signature still referenced by a surviving meter + // change: tombstoning it would strand the reference + // (operation_catalog §CreateStaff undo semantics). + if let Some((blocked, referencer)) = targets + .iter() + .find_map(|t| self.undo_strand_block(t, &targets, &restorations)) + { + let conflict = ConflictRecord::new( + ConflictKind::TransactionConflict { + transaction: op.target, + failed_members: vec![env.id], + }, + vec![env.id], + vec![blocked, referencer], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + return OperationEffect::Conflicted { conflict: cid }; + } + // A written key superseded by a later writer: strict undo + // refuses the whole compensation, naming the undo and the + // (canonically first) superseding writer. + if let Some(by) = superseded.first().copied() { + let conflict = ConflictRecord::new( + ConflictKind::TransactionConflict { + transaction: op.target, + failed_members: vec![env.id], + }, + vec![env.id, by], + vec![], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + return OperationEffect::Conflicted { conflict: cid }; + } + let repairs = self.tombstone_undo_targets(env, &targets); + self.apply_restorations(env, restorations); + if repairs.is_empty() { + OperationEffect::Applied + } else { + OperationEffect::AppliedWithRepair { repairs } } } UndoPolicy::BestEffort => { - let mut repairs = Vec::new(); - let mut tombstoned = Vec::new(); - for t in &targets { - if matches!(self.objects.get(t), Some(ObjectState::Live)) { - let minter = self.minted_by.get(t).copied().unwrap_or(env.id); - self.objects.insert( - *t, - ObjectState::Tombstoned { - deleted_by: env.id, - minted_by: minter, - }, - ); - repairs.push(RepairRecord { - kind: RepairKind::CascadeDeleted, - target: *t, - }); - tombstoned.push(*t); - } + // Tombstone the still-live, non-stranding mints; restore the + // still-last-written keys; skip the rest. + let tombstonable: Vec = targets + .iter() + .filter(|t| matches!(self.objects.get(t), Some(ObjectState::Live))) + .filter(|t| self.undo_strand_block(t, &targets, &restorations).is_none()) + .copied() + .collect(); + let repairs = self.tombstone_undo_targets(env, &tombstonable); + self.apply_restorations(env, restorations); + if repairs.is_empty() { + OperationEffect::Applied + } else { + OperationEffect::AppliedWithRepair { repairs } } - repairs.extend(self.materialize_graph_tombstones(env, &tombstoned)); - OperationEffect::AppliedWithRepair { repairs } } } } + /// Tombstones the minted objects of an undone transaction and materializes + /// the graph-side removals, returning the `CascadeDeleted` repair records. + fn tombstone_undo_targets( + &mut self, + env: &OperationEnvelope, + targets: &[TypedObjectId], + ) -> Vec { + let mut repairs = Vec::new(); + for t in targets { + let minter = self.minted_by.get(t).copied().unwrap_or(env.id); + self.objects.insert( + *t, + ObjectState::Tombstoned { + deleted_by: env.id, + minted_by: minter, + }, + ); + repairs.push(RepairRecord { + kind: RepairKind::CascadeDeleted, + target: *t, + }); + } + repairs.extend(self.materialize_graph_tombstones(env, targets)); + repairs + } + + /// `Some((blocked, referencer))` when tombstoning `target` under undo + /// would strand a live reference: a minted `Staff` still manifested by a + /// live staff instance (operation_catalog §CreateStaff), or a minted + /// `TimeSignature` still referenced by a meter change that survives the + /// restoration pass. References held by objects the same undo tombstones + /// do not block. + fn undo_strand_block( + &self, + target: &TypedObjectId, + targets: &[TypedObjectId], + restorations: &[ValueRestoration], + ) -> Option<(TypedObjectId, TypedObjectId)> { + match target { + TypedObjectId::Staff(staff) => { + self.instance_staff + .iter() + .find_map(|(instance, manifested)| { + let iobj = TypedObjectId::StaffInstance(*instance); + (manifested == staff + && !targets.contains(&iobj) + && matches!(self.objects.get(&iobj), Some(ObjectState::Live))) + .then_some((*target, iobj)) + }) + } + TypedObjectId::TimeSignature(id) => { + self.meter_change_chain + .iter() + .find_map(|((region, position), chain)| { + // The prospective post-undo value at this key: the + // restoration's value where one applies, else the + // chain's current value. + let prospective: Option = restorations + .iter() + .find_map(|restoration| match restoration { + ValueRestoration::MeterChange { + region: r, + position: p, + value, + } if r == region && p == position => Some(value.clone()), + _ => None, + }) + .unwrap_or_else(|| chain.current().cloned().flatten()); + prospective.and_then(|meter| { + (meter.time_signature == *id) + .then_some((*target, TypedObjectId::Region(*region))) + }) + }) + } + _ => None, + } + } + + /// Walks every write chain and collects, for the target transaction: the + /// keys still last-written by it (with their chain-predecessor + /// restorations) and the operations that superseded its other writes + /// (sorted, deduplicated). Keys whose owning object is tombstoned — or is + /// itself one of the transaction's mints, about to be tombstoned by this + /// undo — are skipped entirely: there is no live slot to restore. + fn collect_restorations( + &self, + tx: TransactionId, + targets: &[TypedObjectId], + ) -> (Vec, Vec) { + let mut restorations = Vec::new(); + let mut superseded: Vec = Vec::new(); + let slot_live = |obj: TypedObjectId| { + matches!(self.objects.get(&obj), Some(ObjectState::Live)) && !targets.contains(&obj) + }; + + for (event, chain) in &self.event_modify_chain { + if !slot_live(TypedObjectId::Event(*event)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::Event { + event: *event, + value: predecessor.map(Predecessor::into_value), + }) + } + } + } + for (pitch, chain) in &self.pitch_modify_chain { + if !slot_live(TypedObjectId::Pitch(*pitch)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::Pitch { + pitch: *pitch, + value: predecessor.map(Predecessor::into_value), + }) + } + } + } + for (pitch, chain) in &self.respell_chain { + if !slot_live(TypedObjectId::Pitch(*pitch)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::Spelling { + pitch: *pitch, + predecessor, + }) + } + } + } + for (id, chain) in &self.cross_cutting_modify_chain { + if !slot_live(*id) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::CrossCutting { + id: *id, + value: predecessor.map(Predecessor::into_value), + }) + } + } + } + match self.metadata_chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::Metadata { + value: predecessor.map(Predecessor::into_value), + }) + } + } + for (region, chain) in &self.metric_grid_chain { + if !slot_live(TypedObjectId::Region(*region)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + // Flattened: no predecessor and a cleared-grid predecessor + // both restore "no grid". + restorations.push(ValueRestoration::MetricGrid { + region: *region, + value: match predecessor { + Some(p) => p.into_value(), + None => None, + }, + }) + } + } + } + for ((region, position), chain) in &self.meter_change_chain { + if !slot_live(TypedObjectId::Region(*region)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::MeterChange { + region: *region, + position: position.clone(), + value: match predecessor { + Some(p) => p.into_value(), + None => None, + }, + }) + } + } + } + for ((scope, position), chain) in &self.tempo_segment_chain { + if let Some(region) = scope { + if !slot_live(TypedObjectId::Region(*region)) { + continue; + } + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::TempoSegment { + region: *scope, + position: position.clone(), + value: match predecessor { + Some(p) => p.into_value(), + None => None, + }, + }) + } + } + } + for (instance, chain) in &self.staff_layout_chain { + if !slot_live(TypedObjectId::StaffInstance(*instance)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::StaffLayout { + instance: *instance, + value: predecessor.map(Predecessor::into_value), + }) + } + } + } + for ((region, position), chain) in &self.break_chain { + if !slot_live(TypedObjectId::Region(*region)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::SystemBreak { + region: *region, + position: position.clone(), + predecessor, + }) + } + } + } + for ((region, position), chain) in &self.page_break_chain { + if !slot_live(TypedObjectId::Region(*region)) { + continue; + } + match chain.undo_verdict(tx) { + ChainUndoVerdict::NotWritten => {} + ChainUndoVerdict::Superseded { by } => superseded.push(by), + ChainUndoVerdict::Restore(predecessor) => { + restorations.push(ValueRestoration::PageBreak { + region: *region, + position: position.clone(), + predecessor, + }) + } + } + } + superseded.sort(); + superseded.dedup(); + (restorations, superseded) + } + + /// Applies the collected restorations to the bookkeeping and the graph, + /// recording each restored *value* into its chain as a new write by the + /// undo operation (so a later undo sees the restoration as the key's last + /// writer — the pinned undo-of-undo discipline; see DECISIONS.md). An + /// absence restoration (no predecessor at all) leaves the chain + /// unchanged: a repeated undo of the same transaction re-restores absence + /// idempotently. + fn apply_restorations(&mut self, env: &OperationEnvelope, restorations: Vec) { + for restoration in restorations { + match restoration { + ValueRestoration::Event { event, value } => { + if let Some(value) = value { + self.apply_event_value(&value); + self.event_modify_chain + .entry(event) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value); + } + } + ValueRestoration::Pitch { pitch, value } => { + if let Some(value) = value { + self.graph_modify_pitch(pitch, &value); + self.pitch_modify_chain + .entry(pitch) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value); + } + } + ValueRestoration::Spelling { pitch, predecessor } => match predecessor { + Some(Predecessor::Write(spelling)) => { + self.spellings.insert(pitch, spelling.clone()); + self.respell_chain + .entry(pitch) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, spelling.clone()); + self.graph_respell_pitch(pitch, &spelling); + } + Some(Predecessor::Base(spelling)) => { + // The ledger returns to key-absence (the base state + // lives in the graph); the graph attachment restores + // the base value. + self.spellings.remove(&pitch); + self.respell_chain + .entry(pitch) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, spelling.clone()); + self.graph_respell_pitch(pitch, &spelling); + } + None => { + self.spellings.remove(&pitch); + self.graph_remove_respell(pitch); + } + }, + ValueRestoration::CrossCutting { id, value } => { + if let Some(value) = value { + self.structures.insert(id, value.endpoints()); + self.graph_modify_cross_cutting(&value); + self.cross_cutting_modify_chain + .entry(id) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value); + } + } + ValueRestoration::Metadata { value } => { + if let Some(value) = value { + if let Some(score) = self.graph.as_mut() { + score.metadata = value.clone(); + } + self.metadata_chain.record(env.id, env.transaction, value); + } + } + ValueRestoration::MetricGrid { region, value } => { + self.metric_grid_chain + .entry(region) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value.clone()); + self.graph_set_metric_grid(region, &value); + } + ValueRestoration::MeterChange { + region, + position, + value, + } => { + self.meter_change_chain + .entry((region, position.clone())) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value.clone()); + self.graph_apply_meter_change(region, &position, &value); + } + ValueRestoration::TempoSegment { + region, + position, + value, + } => { + self.tempo_segment_chain + .entry((region, position.clone())) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value.clone()); + self.graph_apply_tempo_segment(region, &position, &value); + } + ValueRestoration::StaffLayout { instance, value } => { + if let Some(value) = value { + self.graph_set_staff_layout(instance, &value); + self.staff_layout_chain + .entry(instance) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, value); + } + } + ValueRestoration::SystemBreak { + region, + position, + predecessor, + } => self.restore_break(env, region, position, predecessor, false), + ValueRestoration::PageBreak { + region, + position, + predecessor, + } => self.restore_break(env, region, position, predecessor, true), + } + } + } + + /// Restores one user-break key: a write predecessor re-enters the ledger + /// map; a base predecessor returns the map to key-absence while the graph + /// restores the base anchor; no predecessor removes the key from map and + /// graph alike. + fn restore_break( + &mut self, + env: &OperationEnvelope, + region: RegionId, + position: MusicalPosition, + predecessor: Option>, + page: bool, + ) { + let key = (region, position.clone()); + match predecessor { + Some(Predecessor::Write((anchor, present))) => { + if page { + self.page_breaks.insert(key.clone(), present); + self.page_break_chain + .entry(key) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, (anchor.clone(), present)); + } else { + self.breaks.insert(key.clone(), present); + self.break_chain + .entry(key) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, (anchor.clone(), present)); + } + self.graph_apply_break(region, &anchor, present, page); + } + Some(Predecessor::Base((anchor, present))) => { + if page { + self.page_breaks.remove(&key); + self.page_break_chain + .entry(key) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, (anchor.clone(), present)); + } else { + self.breaks.remove(&key); + self.break_chain + .entry(key) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, (anchor.clone(), present)); + } + self.graph_apply_break(region, &anchor, present, page); + } + None => { + if page { + self.page_breaks.remove(&key); + } else { + self.breaks.remove(&key); + } + self.graph_clear_break(region, &position, page); + } + } + } + + fn graph_apply_break( + &mut self, + region: RegionId, + anchor: &TimeAnchor, + present: bool, + page: bool, + ) { + let Some(score) = self.graph.as_mut() else { + return; + }; + if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) { + if let Some(content) = region.content.staff_based_mut() { + let list = if page { + &mut content.user_page_breaks + } else { + &mut content.user_system_breaks + }; + apply_break_lww(list, anchor, present); + } + } + } + + fn graph_clear_break(&mut self, region: RegionId, position: &MusicalPosition, page: bool) { + let Some(score) = self.graph.as_mut() else { + return; + }; + if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) { + if let Some(content) = region.content.staff_based_mut() { + let list = if page { + &mut content.user_page_breaks + } else { + &mut content.user_system_breaks + }; + list.retain(|existing| resolved_anchor_position(existing) != *position); + } + } + } + + /// Removes the user-chosen explicit spelling attachment for `pitch` — the + /// inverse of the attachment `graph_respell_pitch` installs, used when a + /// respell restoration has no predecessor (the operation introduced the + /// first spelling). + fn graph_remove_respell(&mut self, pitch: PitchId) { + let Some(score) = self.graph.as_mut() else { + return; + }; + score.spelling_attachments.retain(|a| { + !(a.layer.is_none() + && matches!(a.source, SpellingSource::UserChosen) + && matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch) + && matches!(a.directive, SpellingDirective::Explicit(_))) + }); + } + // --- Group 1 (M2): event & pitch leaf-field ops. ------------------------ // // The modify/transpose ops follow respell's field-overwrite discipline but @@ -3554,9 +5018,10 @@ impl<'a> Reducer<'a> { }; } let prev = self - .last_event_modify + .event_modify_chain .get(&event_id) - .map(|(o, e)| (*o, e.clone())); + .and_then(|chain| chain.last_write()) + .map(|write| (write.op, write.value.clone())); let effect = match prev { Some((prev_op, prev_event)) if self.concurrent(env.id, prev_op) => { if prev_event == op.event { @@ -3582,15 +5047,27 @@ impl<'a> Reducer<'a> { // First modify, or a causally-ordered intentional overwrite. _ => OperationEffect::Applied, }; - self.last_event_modify - .insert(event_id, (env.id, op.event.clone())); - // Materialize a move only when it is a sanctioned metric move (`Moved`) *and* - // the replacement is well-formed — so the graph and the occupancy index move - // together. A malformed (empty) pitched replacement is not materialized in the - // graph (`graph_replace_event` skips it), so it must not move occupancy either. + self.event_modify_chain + .entry(event_id) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, op.event.clone()); + self.apply_event_value(&op.event); + effect + } + + /// Applies an event *value* (a modify's replacement, or an undo's restored + /// predecessor) to the graph and the occupancy index. A move materializes + /// only when it is a sanctioned metric move (`Moved`) *and* the value is + /// well-formed — so the graph and the occupancy index move together. A + /// malformed (empty) pitched value is not materialized in the graph + /// (`graph_replace_event` skips it), so it must not move occupancy either; + /// a refused or non-metric placement leaves placement untouched (only + /// same-placement field edits apply). + fn apply_event_value(&mut self, value: &Event) { + let placement = self.metric_placement_verdict(value); let materialize_move = matches!(placement, PlacementVerdict::Moved { .. }) - && !matches!(&op.event, Event::Pitched(pe) if !pe.is_well_formed()); - self.graph_replace_event(&op.event, materialize_move); + && !matches!(value, Event::Pitched(pe) if !pe.is_well_formed()); + self.graph_replace_event(value, materialize_move); // Keep the voice-occupancy index in step with a materialized move, so a later // insert sees the freed/changed span (the same index its overlap check reads). if materialize_move { @@ -3601,14 +5078,13 @@ impl<'a> Reducer<'a> { } = placement { if let Some(events) = self.voice_occupancy.get_mut(&voice) { - for slot in events.iter_mut().filter(|slot| slot.2 == event_id) { + for slot in events.iter_mut().filter(|slot| slot.2 == value.id()) { slot.0 = position.clone(); slot.1 = duration.clone(); } } } } - effect } /// The verdict on a [`ModifyEvent`](OperationKind::ModifyEvent)'s placement: does @@ -3771,6 +5247,12 @@ impl<'a> Reducer<'a> { self.objects.insert(p_obj, ObjectState::Live); self.minted_by.insert(p_obj, env.id); self.note_minted(env, p_obj); + // Seed the pitch's write chain with the minted value, so a later + // modify's chain-predecessor is the inserted state. + self.pitch_modify_chain + .entry(pitch_id) + .or_insert_with(WriteChain::new) + .seed(op.pitch.pitch.clone()); self.event_pitches .entry(op.event) .or_default() @@ -3836,9 +5318,10 @@ impl<'a> Reducer<'a> { Some(ObjectState::Live) => {} } let prev = self - .last_pitch_modify + .pitch_modify_chain .get(&op.pitch) - .map(|(o, v)| (*o, v.clone())); + .and_then(|chain| chain.last_write()) + .map(|write| (write.op, write.value.clone())); let effect = match prev { Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => { if prev_value == op.value { @@ -3861,8 +5344,10 @@ impl<'a> Reducer<'a> { } _ => OperationEffect::Applied, }; - self.last_pitch_modify - .insert(op.pitch, (env.id, op.value.clone())); + self.pitch_modify_chain + .entry(op.pitch) + .or_insert_with(WriteChain::new) + .record(env.id, env.transaction, op.value.clone()); self.graph_modify_pitch(op.pitch, &op.value); effect } @@ -4879,11 +6364,19 @@ impl<'a> Reducer<'a> { minted_by: self.minted_by.clone(), event_pitches: self.event_pitches.clone(), voice_occupancy: self.voice_occupancy.clone(), - last_respell: self.last_respell.clone(), - last_event_modify: self.last_event_modify.clone(), - last_pitch_modify: self.last_pitch_modify.clone(), - last_cross_cutting_modify: self.last_cross_cutting_modify.clone(), - last_metric_grid: self.last_metric_grid.clone(), + respell_chain: self.respell_chain.clone(), + event_modify_chain: self.event_modify_chain.clone(), + pitch_modify_chain: self.pitch_modify_chain.clone(), + cross_cutting_modify_chain: self.cross_cutting_modify_chain.clone(), + metric_grid_chain: self.metric_grid_chain.clone(), + metadata_chain: self.metadata_chain.clone(), + break_chain: self.break_chain.clone(), + page_break_chain: self.page_break_chain.clone(), + meter_change_chain: self.meter_change_chain.clone(), + tempo_segment_chain: self.tempo_segment_chain.clone(), + staff_layout_chain: self.staff_layout_chain.clone(), + staff_values: self.staff_values.clone(), + time_signature_values: self.time_signature_values.clone(), structures: self.structures.clone(), region_instances: self.region_instances.clone(), instance_voices: self.instance_voices.clone(), @@ -4906,11 +6399,19 @@ impl<'a> Reducer<'a> { self.minted_by = s.minted_by; self.event_pitches = s.event_pitches; self.voice_occupancy = s.voice_occupancy; - self.last_respell = s.last_respell; - self.last_event_modify = s.last_event_modify; - self.last_pitch_modify = s.last_pitch_modify; - self.last_cross_cutting_modify = s.last_cross_cutting_modify; - self.last_metric_grid = s.last_metric_grid; + self.respell_chain = s.respell_chain; + self.event_modify_chain = s.event_modify_chain; + self.pitch_modify_chain = s.pitch_modify_chain; + self.cross_cutting_modify_chain = s.cross_cutting_modify_chain; + self.metric_grid_chain = s.metric_grid_chain; + self.metadata_chain = s.metadata_chain; + self.break_chain = s.break_chain; + self.page_break_chain = s.page_break_chain; + self.meter_change_chain = s.meter_change_chain; + self.tempo_segment_chain = s.tempo_segment_chain; + self.staff_layout_chain = s.staff_layout_chain; + self.staff_values = s.staff_values; + self.time_signature_values = s.time_signature_values; self.structures = s.structures; self.region_instances = s.region_instances; self.instance_voices = s.instance_voices; @@ -6985,4 +8486,561 @@ mod tests { ); } } + + // ========================================================================= + // Phase-3 first tranche: CreateStaff, SetTimeSignature, SetTempoSegment, + // SetStaffLayout, and value-restoring undo (operation_catalog + // §CreateStaff, §"Meter and Tempo Overwrites", §SetStaffLayout, §undo). + // ========================================================================= + + fn tx_member( + replica: u64, + counter: u64, + physical: i64, + ctx: CausalContext, + tx: TransactionId, + kind: OperationKind, + ) -> OperationEnvelope { + let mut env = prim_env(replica, counter, physical, ctx, kind); + env.transaction = Some(tx); + env + } + + fn declare_transaction( + replica: u64, + counter: u64, + physical: i64, + ctx: CausalContext, + tx: TransactionId, + ) -> OperationEnvelope { + tx_member( + replica, + counter, + physical, + ctx, + tx, + OperationKind::DeclareTransaction(crate::payload::TransactionDescriptor { + id: tx, + label: String::from("phase-3 undo scenario"), + category: None, + }), + ) + } + + fn undo_env( + replica: u64, + counter: u64, + physical: i64, + ctx: CausalContext, + target: TransactionId, + policy: UndoPolicy, + ) -> OperationEnvelope { + let id = OperationId::new(ReplicaId(replica), counter); + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: ctx, + transaction: None, + payload: OperationPayload::UndoTransaction(UndoTransactionPayload { target, policy }), + } + } + + fn seen_r1(counter: u64) -> CausalContext { + CausalContext::new().with_seen(ReplicaId(1), counter) + } + + fn respell_kind(pitch: PitchId, nth: u8) -> OperationKind { + OperationKind::RespellPitch(RespellPitchOp { + pitch, + spelling: crate::valuegen::spelling(nth), + }) + } + + #[test] + fn create_staff_set_union_mint_discipline() { + let staff_id = StaffId::new(ReplicaId(9), 7); + let instrument = InstrumentId::new(ReplicaId(9), 1); + let value = crate::valuegen::staff(staff_id, instrument); + let create = prim_env( + 1, + 0, + 10, + CausalContext::new(), + OperationKind::CreateStaff(CreateStaffOp { + staff: value.clone(), + }), + ); + let identical = prim_env( + 2, + 0, + 20, + CausalContext::new(), + OperationKind::CreateStaff(CreateStaffOp { + staff: value.clone(), + }), + ); + let mut differing_value = value.clone(); + differing_value.name = String::from("something else"); + let differing = prim_env( + 3, + 0, + 30, + CausalContext::new(), + OperationKind::CreateStaff(CreateStaffOp { + staff: differing_value, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![differing.clone(), identical.clone(), create.clone()]); + let state = set.reduce(); + + let effect_of = |id: OperationId| { + state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff) + }; + assert_eq!(effect_of(create.id), Some(&OperationEffect::Applied)); + assert_eq!( + effect_of(identical.id), + Some(&OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }), + "a byte-identical re-create reduces idempotently" + ); + assert_eq!( + effect_of(differing.id), + Some(&OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }), + "a differing value under a live id is a precondition no-op" + ); + assert!(matches!( + state.objects.get(&TypedObjectId::Staff(staff_id)), + Some(ObjectState::Live) + )); + } + + fn create_region_env( + replica: u64, + counter: u64, + physical: i64, + region: RegionId, + ) -> OperationEnvelope { + prim_env( + replica, + counter, + physical, + CausalContext::new(), + OperationKind::CreateRegion(CreateRegionOp { + region: crate::valuegen::region(region), + }), + ) + } + + fn set_meter_kind( + region: RegionId, + at: MusicalPosition, + signature: Option, + ) -> OperationKind { + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: crate::valuegen::region_start_anchor(region, at), + time_signature: signature, + }) + } + + #[test] + fn concurrent_differing_meter_writes_collide_on_meter_sequence() { + let region = RegionId::new(ReplicaId(9), 3); + let create = create_region_env(1, 0, 10, region); + let seen = seen_r1(0); + let ts_a = crate::valuegen::time_signature(TimeSignatureId::new(ReplicaId(9), 1), 4); + let ts_b = crate::valuegen::time_signature(TimeSignatureId::new(ReplicaId(9), 2), 3); + let set_a = prim_env( + 2, + 0, + 20, + seen.clone(), + set_meter_kind(region, pos(0), Some(ts_a)), + ); + let set_b = prim_env(3, 0, 20, seen, set_meter_kind(region, pos(0), Some(ts_b))); + let mut set = OperationSet::new(); + set.accept_all(vec![set_b.clone(), set_a.clone(), create]); + let state = set.reduce(); + + assert_eq!(state.conflicts.records().len(), 1); + assert!(matches!( + &state.conflicts.records()[0].kind, + ConflictKind::StructuralFieldCollision { field, winner, loser } + if field.0 == "meter_sequence" && *winner == set_b.id && *loser == set_a.id + )); + } + + #[test] + fn identical_concurrent_meter_writes_reduce_idempotently() { + let region = RegionId::new(ReplicaId(9), 3); + let create = create_region_env(1, 0, 10, region); + let seen = seen_r1(0); + let ts = crate::valuegen::time_signature(TimeSignatureId::new(ReplicaId(9), 1), 4); + let set_a = prim_env( + 2, + 0, + 20, + seen.clone(), + set_meter_kind(region, pos(0), Some(ts.clone())), + ); + let set_b = prim_env(3, 0, 20, seen, set_meter_kind(region, pos(0), Some(ts))); + let mut set = OperationSet::new(); + set.accept_all(vec![set_b.clone(), set_a.clone(), create]); + let state = set.reduce(); + + assert!(state.conflicts.is_empty()); + let effect_of = |id: OperationId| { + state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff) + }; + assert_eq!(effect_of(set_a.id), Some(&OperationEffect::Applied)); + assert_eq!( + effect_of(set_b.id), + Some(&OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }) + ); + } + + #[test] + fn a_differing_recarry_of_a_live_time_signature_is_refused() { + let region = RegionId::new(ReplicaId(9), 3); + let signature_id = TimeSignatureId::new(ReplicaId(9), 1); + let create = create_region_env(1, 0, 10, region); + let set_a = prim_env( + 1, + 1, + 20, + seen_r1(0), + set_meter_kind( + region, + pos(0), + Some(crate::valuegen::time_signature(signature_id, 4)), + ), + ); + // Causally later, same signature id, different value, different key. + let set_b = prim_env( + 1, + 2, + 30, + seen_r1(1), + set_meter_kind( + region, + pos(4), + Some(crate::valuegen::time_signature(signature_id, 3)), + ), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![create, set_a, set_b.clone()]); + let state = set.reduce(); + + assert_eq!( + state + .effects + .iter() + .find(|(e, _)| *e == set_b.id) + .map(|(_, eff)| eff), + Some(&OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }), + "a differing value under a live signature id is a precondition no-op" + ); + } + + #[test] + fn set_tempo_segment_refuses_writes_that_would_malform_the_map() { + let region = RegionId::new(ReplicaId(9), 0); + let anchor_at = |at: i64| crate::valuegen::region_start_anchor(region, pos(at)); + let malformed = |counter: u64, op: SetTempoSegmentOp| { + prim_env( + 1, + counter, + (counter as i64 + 1) * 10, + if counter == 0 { + CausalContext::new() + } else { + seen_r1(counter - 1) + }, + OperationKind::SetTempoSegment(op), + ) + }; + // (0) A clean open constant segment at 0 applies (score scope). + let clean = malformed( + 0, + SetTempoSegmentOp { + region: None, + start: anchor_at(0), + segment: Some(crate::valuegen::tempo_segment(region, pos(0), 120.0)), + }, + ); + // (1) Carried segment start disagrees with the operation's key. + let key_mismatch = malformed( + 1, + SetTempoSegmentOp { + region: None, + start: anchor_at(2), + segment: Some(crate::valuegen::tempo_segment(region, pos(3), 90.0)), + }, + ); + // (2) A non-constant shape missing its end data. + let mut ramp = crate::valuegen::tempo_segment(region, pos(4), 60.0); + ramp.shape = epiphany_core::TempoShape::Linear; + let missing_end = malformed( + 2, + SetTempoSegmentOp { + region: None, + start: anchor_at(4), + segment: Some(ramp), + }, + ); + // (3) An explicit end overlapping the next segment: a segment at -4 + // whose end (at 2) runs past the existing segment's start at 0. + let mut overlapping = crate::valuegen::tempo_segment(region, pos(-4), 100.0); + overlapping.end = Some(anchor_at(2)); + let overlap = malformed( + 3, + SetTempoSegmentOp { + region: None, + start: anchor_at(-4), + segment: Some(overlapping), + }, + ); + let mut set = OperationSet::new(); + set.accept_all(vec![ + clean.clone(), + key_mismatch.clone(), + missing_end.clone(), + overlap.clone(), + ]); + let state = set.reduce(); + + let effect_of = |id: OperationId| { + state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff) + }; + assert_eq!(effect_of(clean.id), Some(&OperationEffect::Applied)); + let refused = OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TempoMapMalformed, + }, + }; + assert_eq!(effect_of(key_mismatch.id), Some(&refused)); + assert_eq!(effect_of(missing_end.id), Some(&refused)); + assert_eq!(effect_of(overlap.id), Some(&refused)); + } + + /// The base scenario of the value-restoring undo unit tests: an event with + /// one pitch (r1c0), a pre-transaction respell to `spelling(1)` (r1c1), a + /// transaction T (declared r1c2) whose member respells to `spelling(2)` + /// (r1c3). + fn respell_undo_fixture(tx: TransactionId) -> (PitchId, Vec) { + let pitch = PitchId::new(ReplicaId(1), 50); + let e0 = + insert_with_pitch_content(1, 0, 10, 1, 100, 0, pitch, &crate::valuegen::pitch_value()); + let pre = prim_env(1, 1, 20, seen_r1(0), respell_kind(pitch, 1)); + let declare = declare_transaction(1, 2, 30, seen_r1(1), tx); + let member = tx_member(1, 3, 40, seen_r1(2), tx, respell_kind(pitch, 2)); + (pitch, vec![e0, pre, declare, member]) + } + + #[test] + fn undo_restores_the_chain_predecessor_spelling() { + let tx = TransactionId::from_raw(21); + let (pitch, mut envelopes) = respell_undo_fixture(tx); + envelopes.push(undo_env( + 1, + 4, + 50, + seen_r1(3), + tx, + UndoPolicy::StrictInverse, + )); + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let state = set.reduce(); + + assert!(state.conflicts.is_empty()); + assert_eq!( + state.spellings.get(&pitch), + Some(&crate::valuegen::spelling(1)), + "undo must restore the pre-transaction spelling" + ); + // A pure value restoration (no mints) is a clean `Applied`. + assert_eq!(effect_at(&state, 4), Some(&OperationEffect::Applied)); + } + + #[test] + fn undo_removes_a_first_spelling_write() { + // No pre-transaction respell: the transaction introduced the first + // spelling, so undo restores absence. + let tx = TransactionId::from_raw(22); + let pitch = PitchId::new(ReplicaId(1), 50); + let e0 = + insert_with_pitch_content(1, 0, 10, 1, 100, 0, pitch, &crate::valuegen::pitch_value()); + let declare = declare_transaction(1, 1, 20, seen_r1(0), tx); + let member = tx_member(1, 2, 30, seen_r1(1), tx, respell_kind(pitch, 2)); + let undo = undo_env(1, 3, 40, seen_r1(2), tx, UndoPolicy::StrictInverse); + let mut set = OperationSet::new(); + set.accept_all(vec![undo, member, declare, e0]); + let state = set.reduce(); + + assert!(state.conflicts.is_empty()); + assert_eq!(state.spellings.get(&pitch), None); + assert_eq!(effect_at(&state, 3), Some(&OperationEffect::Applied)); + } + + #[test] + fn superseded_undo_conflicts_strict_and_skips_best_effort() { + let tx = TransactionId::from_raw(23); + let (pitch, mut envelopes) = respell_undo_fixture(tx); + // A causally-later respell supersedes the transaction's write. + let superseder = prim_env(1, 4, 50, seen_r1(3), respell_kind(pitch, 3)); + let strict = undo_env(1, 5, 60, seen_r1(4), tx, UndoPolicy::StrictInverse); + let best_effort = undo_env(1, 6, 70, seen_r1(5), tx, UndoPolicy::BestEffort); + envelopes.extend([superseder.clone(), strict.clone(), best_effort.clone()]); + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let state = set.reduce(); + + // StrictInverse refuses the whole undo, naming the undo and the + // superseding writer. + assert!(matches!( + effect_at(&state, 5), + Some(OperationEffect::Conflicted { .. }) + )); + let record = state + .conflicts + .records() + .iter() + .find(|record| record.caused_by.contains(&strict.id)) + .expect("the strict undo records a conflict"); + assert!(matches!( + &record.kind, + ConflictKind::TransactionConflict { transaction, .. } if *transaction == tx + )); + assert!(record.caused_by.contains(&superseder.id)); + // BestEffort skips the superseded key: applied, nothing restored. + assert_eq!(effect_at(&state, 6), Some(&OperationEffect::Applied)); + assert_eq!( + state.spellings.get(&pitch), + Some(&crate::valuegen::spelling(3)), + "the superseding write stands" + ); + } + + #[test] + fn undo_of_undo_restores_the_pre_undo_value() { + // PINNED (see DECISIONS.md): an undo's restoration enters the write + // chain as a new write by the undo operation. Undoing the undo's own + // enclosing transaction therefore restores the value the first undo + // removed. + let tx = TransactionId::from_raw(24); + let (pitch, mut envelopes) = respell_undo_fixture(tx); + let undo_tx = TransactionId::from_raw(25); + let declare_undo_tx = declare_transaction(1, 4, 50, seen_r1(3), undo_tx); + let mut first_undo = undo_env(1, 5, 60, seen_r1(4), tx, UndoPolicy::StrictInverse); + first_undo.transaction = Some(undo_tx); + let second_undo = undo_env(1, 6, 70, seen_r1(5), undo_tx, UndoPolicy::StrictInverse); + envelopes.extend([declare_undo_tx, first_undo, second_undo]); + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let state = set.reduce(); + + assert!(state.conflicts.is_empty()); + assert_eq!( + state.spellings.get(&pitch), + Some(&crate::valuegen::spelling(2)), + "undoing the undo restores the originally-undone spelling" + ); + } + + #[test] + fn a_second_undo_of_the_same_transaction_sees_the_first_as_superseding() { + // PINNED (see DECISIONS.md): the first undo's restoration is a write, + // so a repeated strict undo of the same transaction conflicts rather + // than double-restoring. + let tx = TransactionId::from_raw(26); + let (pitch, mut envelopes) = respell_undo_fixture(tx); + let first = undo_env(1, 4, 50, seen_r1(3), tx, UndoPolicy::StrictInverse); + let second = undo_env(1, 5, 60, seen_r1(4), tx, UndoPolicy::StrictInverse); + envelopes.extend([first, second]); + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let state = set.reduce(); + + assert_eq!( + state.spellings.get(&pitch), + Some(&crate::valuegen::spelling(1)), + "the first undo's restoration stands" + ); + assert!(matches!( + effect_at(&state, 5), + Some(OperationEffect::Conflicted { .. }) + )); + } + + #[test] + fn undo_restoration_is_permutation_invariant() { + // The write chains append in canonical processing order, so the undo's + // restoration verdict and restored values are pure functions of the + // operation set: any delivery order reduces to byte-identical state. + let tx = TransactionId::from_raw(27); + let (_, mut envelopes) = respell_undo_fixture(tx); + // Add a meter overwrite + undo flavor alongside the respell flavor. + let region = RegionId::new(ReplicaId(9), 3); + envelopes.push(prim_env( + 2, + 0, + 15, + CausalContext::new(), + OperationKind::CreateRegion(CreateRegionOp { + region: crate::valuegen::region(region), + }), + )); + envelopes.push(undo_env( + 1, + 4, + 50, + seen_r1(3), + tx, + UndoPolicy::StrictInverse, + )); + + let baseline = { + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + set.reduce().canonical_bytes() + }; + let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0x9E37_79B9_7F4A_7C15); + for _ in 0..4 { + let mut shuffled = envelopes.clone(); + shuffle_envelopes(&mut shuffled, &mut rng); + let mut set = OperationSet::new(); + set.accept_all(shuffled); + assert_eq!( + set.reduce().canonical_bytes(), + baseline, + "value-restoring undo must be permutation-invariant" + ); + } + } } diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index 645eb4c..9de3bd3 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -92,6 +92,11 @@ pub enum V0OperationKind { SetMetadata(crate::payload::SetMetadataOp), SetMetricGrid(crate::payload::SetMetricGridOp), SetUserPageBreak(crate::payload::SetUserPageBreakOp), + // Phase-3 first tranche — also v1-native; round-trip by identity. + CreateStaff(crate::payload::CreateStaffOp), + SetTimeSignature(crate::payload::SetTimeSignatureOp), + SetTempoSegment(crate::payload::SetTempoSegmentOp), + SetStaffLayout(crate::payload::SetStaffLayoutOp), } /// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant diff --git a/crates/epiphany-ops/src/validate.rs b/crates/epiphany-ops/src/validate.rs index a34bc22..7055bf1 100644 --- a/crates/epiphany-ops/src/validate.rs +++ b/crates/epiphany-ops/src/validate.rs @@ -40,6 +40,14 @@ //! applies to it identically (the replacement's span must not straddle the //! region boundary any more than an inserted one may). //! +//! The Phase-3 first tranche (`CreateStaff`, `SetTimeSignature`, +//! `SetTempoSegment`, `SetStaffLayout` — operation_catalog §CreateStaff, +//! §"Meter and Tempo Overwrites", §SetStaffLayout) declares **no advisory +//! preconditions**: every precondition those entries name (reference +//! resolution, mint freshness, resulting-map well-formedness, target +//! liveness) is invariant and enforced by the reducer in all modes, so this +//! module gains no new checks for them. +//! //! ### Implemented here //! //! * InsertEvent / ModifyEvent duration-not-crossing-region-boundary diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs index 5a4bb9a..1f4f435 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -288,6 +288,74 @@ pub fn metric_grid() -> epiphany_core::MetricGrid { } } +/// A minimal global [`Staff`](epiphany_core::Staff) (Phase-3 tranche) — the +/// value a `CreateStaff` mints: a five-line staff named for its counter, +/// referencing `instrument`, with no abbreviation or group. +pub fn staff(id: StaffId, instrument: epiphany_core::InstrumentId) -> epiphany_core::Staff { + epiphany_core::Staff { + id, + name: format!("staff-{}", id.counter()), + abbreviation: None, + instrument, + default_staff_lines: epiphany_core::StaffLineConfiguration::default(), + group: None, + } +} + +/// A well-formed `numerator`/4 [`TimeSignature`](epiphany_core::TimeSignature) +/// (Phase-3 tranche): `numerator` quarter-note beat groups summing exactly to +/// the measure duration, so [`epiphany_core::TimeSignature::new`]'s beat-group +/// invariant holds by construction. Distinct numerators give distinct values, +/// letting a harness drive the set-union mint's identical/differing re-carry +/// branches deterministically. +pub fn time_signature( + id: epiphany_core::TimeSignatureId, + numerator: u16, +) -> epiphany_core::TimeSignature { + let numerator = numerator.max(1); + let quarter = MusicalDuration(epiphany_core::RationalTime::new(1, 4).expect("1/4 is valid")); + let measure = MusicalDuration( + epiphany_core::RationalTime::new(numerator as i64, 4).expect("n/4 is valid"), + ); + let beat_groups = (0..numerator) + .map(|i| epiphany_core::BeatGroup { + duration: quarter.clone(), + subdivision: None, + accent: u8::from(i == 0), + }) + .collect(); + epiphany_core::TimeSignature::new( + id, + epiphany_core::TimeSignatureDisplay::Standard { + numerator, + denominator: epiphany_core::PowerOfTwo::new(4).expect("4 is a power of two"), + }, + measure, + beat_groups, + ) + .expect("beat groups sum to the measure duration by construction") +} + +/// A constant [`TempoSegment`](epiphany_core::TempoSegment) (Phase-3 tranche) +/// starting at the given region-relative musical position, open-ended, at +/// `bpm` quarter-note beats per minute. Its start anchor resolves (under the +/// operation layer's coarse anchor resolution) to exactly `start`, so it +/// satisfies `SetTempoSegment`'s start-key agreement precondition when keyed +/// by the same anchor. +pub fn tempo_segment( + region: RegionId, + start: MusicalPosition, + bpm: f64, +) -> epiphany_core::TempoSegment { + epiphany_core::TempoSegment { + start: region_start_anchor(region, start), + end: None, + start_tempo: epiphany_core::Tempo::quarter(bpm).expect("positive finite bpm"), + end_tempo: None, + shape: epiphany_core::TempoShape::Constant, + } +} + /// An explicit, user-chosen per-pitch [`SpellingAttachment`] — the engraved-layer /// spelling a materialized score carries after a `RespellPitch`. The v0 → v1 /// migration recovers a respell's spelling from exactly these attachments diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index 9843db7..1b9837f 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -6,16 +6,18 @@ use epiphany_core::{ AnchorOffset, AnnotationAnchor, Comment, CommentId, CueEvent, CueRendering, Event, EventDuration, EventId, EventPosition, GestureAnchoring, GraphicGesture, GraphicGestureId, Marker, MarkerId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, - RegionEdge, RegionTimeModel, ReplicaId, Score, SlurId, StaffInstanceId, TimeAnchor, - TransactionId, TypedObjectId, VoiceId, VoiceOrigin, WallClockTime, + RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, SlurId, StaffId, StaffInstanceId, + TimeAnchor, TimeSignatureId, TransactionId, TypedObjectId, VoiceId, VoiceOrigin, WallClockTime, }; use epiphany_ops::{ valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp, - CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, - OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet, - OperationStamp, PositionRemapping, PreconditionFailureReason, ReanchorReason, RepairKind, - RepairRecord, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, - TupletCompensation, UndoPolicy, UndoTransactionPayload, + CreateStaffInstanceOp, CreateStaffOp, CrossCuttingValue, DeleteEventOp, DeleteStaffInstanceOp, + HybridLogicalClock, InsertEventOp, ModifyEventOp, NoOpReason, OperationEffect, + OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, + PositionRemapping, PreconditionFailureReason, ReanchorReason, RepairKind, RepairRecord, + RespellPitchOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, + SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TupletCompensation, + UndoPolicy, UndoTransactionPayload, }; fn envelope( @@ -2751,3 +2753,901 @@ fn referent_reanchoring_is_permutation_invariant() { ); } } + +// =========================================================================== +// Phase-3 first tranche: CreateStaff, SetTimeSignature, SetTempoSegment, +// SetStaffLayout (operation_catalog §CreateStaff, §"Meter and Tempo +// Overwrites", §SetStaffLayout), and value-restoring undo (§UndoTransaction). +// =========================================================================== + +fn seen(replica: u64, counter: u64) -> CausalContext { + CausalContext::new().with_seen(ReplicaId(replica), counter) +} + +fn prim(kind: OperationKind) -> OperationPayload { + OperationPayload::Primitive(kind) +} + +/// A single-replica causal chain of primitives (each member sees its +/// predecessor), optionally under one transaction. +fn chain( + replica: u64, + start_physical: i64, + tx: Option, + kinds: Vec, +) -> Vec { + kinds + .into_iter() + .enumerate() + .map(|(index, kind)| { + let counter = index as u64; + let ctx = if counter == 0 { + CausalContext::new() + } else { + seen(replica, counter - 1) + }; + envelope( + replica, + counter, + start_physical + counter as i64, + ctx, + tx, + prim(kind), + ) + }) + .collect() +} + +fn set_meter( + region: RegionId, + at: i32, + signature: Option, +) -> OperationKind { + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: valuegen::region_start_anchor(region, MusicalPosition(RationalTime::from_int(at))), + time_signature: signature, + }) +} + +fn set_tempo( + region_scope: Option, + anchor_region: RegionId, + at: i32, + segment: Option, +) -> OperationKind { + OperationKind::SetTempoSegment(SetTempoSegmentOp { + region: region_scope, + start: valuegen::region_start_anchor( + anchor_region, + MusicalPosition(RationalTime::from_int(at)), + ), + segment, + }) +} + +fn effect_for(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> &OperationEffect { + result + .state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, effect)| effect) + .expect("every accepted operation produces an effect") +} + +fn precondition_noop(reason: PreconditionFailureReason) -> OperationEffect { + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + } +} + +#[test] +fn create_staff_mints_into_the_graph_and_checks_references() { + let base = epiphany_core::generators::valid_score(300); + let instrument = base.instruments[0].id; + let staff_id = StaffId::new(ReplicaId(60), 1); + let envelopes = chain( + 60, + 10, + None, + vec![ + OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff(staff_id, instrument), + }), + // A staff naming an undeclared instrument is refused (graph-aware + // reference-resolution precondition). + OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff( + StaffId::new(ReplicaId(60), 2), + epiphany_core::InstrumentId::new(ReplicaId(60), 99), + ), + }), + ], + ); + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + let result = set.reduce_onto(&base); + + assert_eq!( + effect_for(&result, envelopes[0].id), + &OperationEffect::Applied + ); + assert_eq!( + effect_for(&result, envelopes[1].id), + &precondition_noop(PreconditionFailureReason::TargetMissing), + ); + assert!(result.score.staves.iter().any(|s| s.id == staff_id)); + assert!(matches!( + result.state.objects.get(&TypedObjectId::Staff(staff_id)), + Some(epiphany_ops::ObjectState::Live) + )); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn create_staff_instance_now_requires_a_live_staff() { + let base = epiphany_core::generators::valid_score(301); + let region = base.canvas.regions[0].id; + let instrument = base.instruments[0].id; + let minted_staff = StaffId::new(ReplicaId(61), 1); + let envelopes = chain( + 61, + 10, + None, + vec![ + // Referencing a staff that was never minted: refused. + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region, + instance: valuegen::staff_instance( + StaffInstanceId::new(ReplicaId(61), 5), + StaffId::new(ReplicaId(61), 9), + ), + }), + // Mint the staff, then an instance referencing it applies. + OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff(minted_staff, instrument), + }), + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region, + instance: valuegen::staff_instance( + StaffInstanceId::new(ReplicaId(61), 6), + minted_staff, + ), + }), + ], + ); + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + let result = set.reduce_onto(&base); + + assert_eq!( + effect_for(&result, envelopes[0].id), + &precondition_noop(PreconditionFailureReason::TargetMissing), + ); + assert_eq!( + effect_for(&result, envelopes[2].id), + &OperationEffect::Applied + ); + assert!(result + .score + .staff_instances() + .any(|(_, si)| si.id == StaffInstanceId::new(ReplicaId(61), 6))); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn set_time_signature_sets_replaces_and_removes_in_the_grid() { + let base = epiphany_core::generators::valid_score(302); + let region = base.canvas.regions[0].id; + let ts_a = valuegen::time_signature(TimeSignatureId::new(ReplicaId(62), 1), 4); + let ts_b = valuegen::time_signature(TimeSignatureId::new(ReplicaId(62), 2), 3); + let envelopes = chain( + 62, + 10, + None, + vec![ + set_meter(region, 0, Some(ts_a.clone())), + set_meter(region, 0, Some(ts_b.clone())), + set_meter(region, 0, None), + ], + ); + + // Set: the grid is created around the meter change; the signature mints. + let mut set = OperationSet::new(); + set.accept_all(envelopes[..1].to_vec()); + let result = set.reduce_onto(&base); + let grid = |result: &epiphany_ops::GraphMaterialization| { + result.score.canvas.regions[0] + .content + .staff_based() + .expect("fixture is staff based") + .default_metric_grid + .clone() + }; + let after_set = grid(&result).expect("a set creates the grid"); + assert_eq!(after_set.meter_sequence.len(), 1); + assert_eq!(after_set.meter_sequence[0].time_signature, ts_a.id); + assert!(result.score.time_signatures.iter().any(|t| t.id == ts_a.id)); + assert!(check_invariants(&result.score).is_empty()); + + // Replace: the causally-later write overwrites the single slot. + let mut set = OperationSet::new(); + set.accept_all(envelopes[..2].to_vec()); + let result = set.reduce_onto(&base); + let after_replace = grid(&result).expect("still present after replace"); + assert_eq!(after_replace.meter_sequence.len(), 1); + assert_eq!(after_replace.meter_sequence[0].time_signature, ts_b.id); + assert!(check_invariants(&result.score).is_empty()); + + // Remove: the slot empties; the grid the set created normalizes away. + let mut set = OperationSet::new(); + set.accept_all(envelopes.to_vec()); + let result = set.reduce_onto(&base); + assert_eq!(grid(&result), None); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn a_mid_region_meter_change_reduces_cleanly_p12_c5() { + // P12-C5: a mid-region SetTimeSignature reduces cleanly and materializes + // into the grid; the decomposition pre-pass still honors only the first + // governing meter (P12-H4) but must not crash on the multi-meter grid. + let base = epiphany_core::generators::valid_score(303); + let region = base.canvas.regions[0].id; + let ts_a = valuegen::time_signature(TimeSignatureId::new(ReplicaId(63), 1), 4); + let ts_b = valuegen::time_signature(TimeSignatureId::new(ReplicaId(63), 2), 3); + let envelopes = chain( + 63, + 10, + None, + vec![ + set_meter(region, 0, Some(ts_a.clone())), + set_meter(region, 8, Some(ts_b.clone())), + ], + ); + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + let result = set.reduce_onto(&base); + + for env in &envelopes { + assert_eq!(effect_for(&result, env.id), &OperationEffect::Applied); + } + let grid = result.score.canvas.regions[0] + .content + .staff_based() + .expect("fixture is staff based") + .default_metric_grid + .as_ref() + .expect("the sets created the grid"); + assert_eq!( + grid.meter_sequence + .iter() + .map(|m| m.time_signature) + .collect::>(), + vec![ts_a.id, ts_b.id], + "meter changes stay ordered by resolved position" + ); + assert!(check_invariants(&result.score).is_empty()); + // The pre-pass tolerates the multi-meter grid (P12-H4 owns honoring it). + let _ = + epiphany_core::derive_annotations(&result.score, &epiphany_core::PrePassProfile::default()); +} + +#[test] +fn set_tempo_segment_materializes_in_score_and_region_scope() { + let base = epiphany_core::generators::valid_score(304); + let region = base.canvas.regions[0].id; + let score_seg = valuegen::tempo_segment(region, MusicalPosition::origin(), 120.0); + let local_seg = valuegen::tempo_segment(region, MusicalPosition::origin(), 90.0); + let mut ramp = + valuegen::tempo_segment(region, MusicalPosition(RationalTime::from_int(4)), 60.0); + ramp.shape = epiphany_core::TempoShape::Linear; // no end data: malformed + let envelopes = chain( + 64, + 10, + None, + vec![ + set_tempo(None, region, 0, Some(score_seg.clone())), + set_tempo(Some(region), region, 0, Some(local_seg.clone())), + set_tempo(None, region, 4, Some(ramp)), + set_tempo(Some(region), region, 0, None), + ], + ); + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + let result = set.reduce_onto(&base); + + assert_eq!( + effect_for(&result, envelopes[0].id), + &OperationEffect::Applied + ); + assert_eq!( + effect_for(&result, envelopes[1].id), + &OperationEffect::Applied + ); + assert_eq!( + effect_for(&result, envelopes[2].id), + &precondition_noop(PreconditionFailureReason::TempoMapMalformed), + ); + assert_eq!( + effect_for(&result, envelopes[3].id), + &OperationEffect::Applied + ); + assert_eq!(result.score.tempo_map.segments, vec![score_seg]); + // The local map was created by the set and normalized away by the remove. + assert_eq!(result.score.canvas.regions[0].local_tempo_map, None); + assert!(check_invariants(&result.score).is_empty()); + + // The region-scoped set alone creates (and keeps) the local map. + let mut set = OperationSet::new(); + set.accept_all(envelopes[..2].to_vec()); + let result = set.reduce_onto(&base); + let local = result.score.canvas.regions[0] + .local_tempo_map + .as_ref() + .expect("a set on a region with no local map creates one"); + assert_eq!(local.segments, vec![local_seg]); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn set_staff_layout_is_an_advisory_lww_with_tombstone_noop() { + let base = epiphany_core::generators::valid_score(305); + let region = base.canvas.regions[0].id; + let instance = base.canvas.regions[0].staff_instances()[0].id; + let staff = base.canvas.regions[0].staff_instances()[0].staff; + let instrument = base.instruments[0].id; + + // Two concurrent differing writes: advisory LWW — no conflict; the later + // in canonical order (greater replica at an equal stamp) wins. + let earlier = envelope( + 65, + 0, + 10, + CausalContext::new(), + None, + prim(OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: instance, + instrument_override: Some(instrument), + staff_lines_override: None, + visible: true, + })), + ); + let later = envelope( + 66, + 0, + 10, + CausalContext::new(), + None, + prim(OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: instance, + instrument_override: None, + staff_lines_override: Some(epiphany_core::StaffLineConfiguration { line_count: 1 }), + visible: false, + })), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![earlier.clone(), later.clone()]); + let result = set.reduce_onto(&base); + + assert!( + result.state.conflicts.is_empty(), + "advisory LWW never conflicts" + ); + assert_eq!(effect_for(&result, earlier.id), &OperationEffect::Applied); + assert_eq!(effect_for(&result, later.id), &OperationEffect::Applied); + let materialized = result + .score + .staff_instances() + .find(|(_, si)| si.id == instance) + .expect("instance survives") + .1 + .clone(); + assert_eq!(materialized.instrument_override, None); + assert_eq!( + materialized.staff_lines_override, + Some(epiphany_core::StaffLineConfiguration { line_count: 1 }) + ); + assert!(!materialized.visible); + assert!(check_invariants(&result.score).is_empty()); + + // A tombstoned target is a no-op: mint an empty instance, delete it, then + // aim a layout write at it. + let fresh = StaffInstanceId::new(ReplicaId(67), 1); + let envelopes = chain( + 67, + 10, + None, + vec![ + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region, + instance: valuegen::staff_instance(fresh, staff), + }), + OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp { + staff_instance: fresh, + }), + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: fresh, + instrument_override: None, + staff_lines_override: None, + visible: false, + }), + ], + ); + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + let result = set.reduce_onto(&base); + assert_eq!( + effect_for(&result, envelopes[2].id), + &OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + }, + ); +} + +/// The full value-restoring undo sweep: one transaction overwrites every LWW +/// family (and mints an event), and its undo restores each family to the base +/// state (operation_catalog §UndoTransaction "Value restoration"). +#[test] +fn undo_restores_overwritten_values_across_every_family() { + let base = epiphany_core::generators::valid_score(306); + let region = base.canvas.regions[0].id; + let (staff_instance, target_voice) = target(&base); + let base_instance = base + .staff_instances() + .find(|(_, si)| si.id == staff_instance) + .expect("fixture instance") + .1 + .clone(); + let base_event_id = base_instance.voices[0].events[0]; + let base_event = base.events.get(base_event_id).expect("base event").clone(); + let mut pitches = Vec::new(); + base_event.collect_identified_pitches(&mut pitches); + let base_pitch = pitches[0].id; + + // A same-placement replacement value with different pitch content. + let mut replacement = base_event.clone(); + if let Event::Pitched(pe) = &mut replacement { + pe.pitches[0].pitch = valuegen::pitch_value_nth(5); + } + let ts = valuegen::time_signature(TimeSignatureId::new(ReplicaId(70), 1), 4); + let segment = valuegen::tempo_segment(region, MusicalPosition::origin(), 120.0); + let inserted_event = EventId::new(ReplicaId(70), 100); + let inserted_pitch = PitchId::new(ReplicaId(70), 101); + + let tx = TransactionId::from_raw(70); + let mut kinds = vec![ + OperationKind::DeclareTransaction(TransactionDescriptor { + id: tx, + label: String::from("overwrite everything"), + category: Some(TransactionCategory::Structural), + }), + OperationKind::ModifyEvent(ModifyEventOp { + event: replacement.clone(), + }), + OperationKind::RespellPitch(RespellPitchOp { + pitch: base_pitch, + spelling: valuegen::spelling(3), + }), + OperationKind::SetMetricGrid(SetMetricGridOp { + region, + grid: Some(valuegen::metric_grid()), + }), + OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { + region, + anchor: valuegen::region_start_anchor( + region, + MusicalPosition(RationalTime::from_int(8)), + ), + present: true, + }), + set_meter(region, 0, Some(ts.clone())), + set_tempo(None, region, 0, Some(segment)), + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance, + instrument_override: None, + staff_lines_override: None, + visible: false, + }), + OperationKind::SetMetadata(epiphany_ops::SetMetadataOp { + metadata: valuegen::score_metadata(7), + }), + ]; + kinds.push(OperationKind::InsertEvent(InsertEventOp { + staff_instance, + event: valuegen::insert_event_value( + inserted_event, + target_voice, + MusicalPosition(RationalTime::from_int(100)), + MusicalDuration::whole(), + &[inserted_pitch], + ), + })); + let n = kinds.len() as u64; + let mut envelopes = chain(70, 10, Some(tx), kinds); + let undo = envelope( + 70, + n, + 10 + n as i64, + seen(70, n - 1), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::StrictInverse, + }), + ); + envelopes.push(undo.clone()); + + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let result = set.reduce_onto(&base); + + // Effect: the tombstone repairs only (the inserted event + pitch, and the + // transaction-minted time signature); restorations are not repairs. + match effect_for(&result, undo.id) { + OperationEffect::AppliedWithRepair { repairs } => { + assert!(repairs + .iter() + .all(|r| matches!(r.kind, RepairKind::CascadeDeleted))); + let targets: Vec = repairs.iter().map(|r| r.target).collect(); + assert!(targets.contains(&TypedObjectId::Event(inserted_event))); + assert!(targets.contains(&TypedObjectId::TimeSignature(ts.id))); + } + other => panic!("expected AppliedWithRepair, got {other:?}"), + } + assert!(result.state.conflicts.is_empty()); + + // Every overwritten family is back at its base value. + assert_eq!( + result.score.events.get(base_event_id), + Some(&base_event), + "the modified event's base value is restored" + ); + assert_eq!(result.state.spellings.get(&base_pitch), None); + assert!(result.score.spelling_attachments.is_empty()); + let content = result.score.canvas.regions[0] + .content + .staff_based() + .expect("fixture is staff based"); + assert_eq!(content.default_metric_grid, None); + assert!(content.user_system_breaks.is_empty()); + assert!(result.state.breaks.is_empty()); + assert!(result.score.tempo_map.segments.is_empty()); + assert!(!result.score.time_signatures.iter().any(|t| t.id == ts.id)); + let restored_instance = result + .score + .staff_instances() + .find(|(_, si)| si.id == staff_instance) + .expect("instance survives") + .1 + .clone(); + assert_eq!( + ( + restored_instance.instrument_override, + restored_instance.staff_lines_override.clone(), + restored_instance.visible + ), + ( + base_instance.instrument_override, + base_instance.staff_lines_override.clone(), + base_instance.visible + ) + ); + assert_eq!(result.score.metadata, base.metadata); + assert!(!result.score.events.contains(inserted_event)); + assert!(check_invariants(&result.score).is_empty()); +} + +/// Replaced (rather than first-written) keys restore the *pre-transaction* +/// writes, not absence. +#[test] +fn undo_restores_the_pre_transaction_writes_for_replaced_keys() { + let base = epiphany_core::generators::valid_score(307); + let region = base.canvas.regions[0].id; + let (staff_instance, _) = target(&base); + let ts_pre = valuegen::time_signature(TimeSignatureId::new(ReplicaId(71), 1), 4); + let ts_tx = valuegen::time_signature(TimeSignatureId::new(ReplicaId(71), 2), 3); + let seg_pre = valuegen::tempo_segment(region, MusicalPosition::origin(), 120.0); + let seg_tx = valuegen::tempo_segment(region, MusicalPosition::origin(), 90.0); + let tx = TransactionId::from_raw(71); + + let kinds = vec![ + // Pre-transaction writers. + set_meter(region, 0, Some(ts_pre.clone())), + set_tempo(None, region, 0, Some(seg_pre.clone())), + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance, + instrument_override: None, + staff_lines_override: None, + visible: false, + }), + // The transaction replaces all three keys. + OperationKind::DeclareTransaction(TransactionDescriptor { + id: tx, + label: String::from("replace"), + category: None, + }), + set_meter(region, 0, Some(ts_tx.clone())), + set_tempo(None, region, 0, Some(seg_tx)), + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance, + instrument_override: None, + staff_lines_override: None, + visible: true, + }), + ]; + let mut envelopes: Vec = kinds + .into_iter() + .enumerate() + .map(|(index, kind)| { + let counter = index as u64; + let ctx = if counter == 0 { + CausalContext::new() + } else { + seen(71, counter - 1) + }; + let tx_of = (counter >= 3).then_some(tx); + envelope(71, counter, 10 + counter as i64, ctx, tx_of, prim(kind)) + }) + .collect(); + let undo = envelope( + 71, + 7, + 20, + seen(71, 6), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::StrictInverse, + }), + ); + envelopes.push(undo.clone()); + + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let result = set.reduce_onto(&base); + + assert!(result.state.conflicts.is_empty()); + let grid = result.score.canvas.regions[0] + .content + .staff_based() + .expect("fixture is staff based") + .default_metric_grid + .as_ref() + .expect("the pre-transaction meter survives"); + assert_eq!(grid.meter_sequence.len(), 1); + assert_eq!(grid.meter_sequence[0].time_signature, ts_pre.id); + // The transaction-minted signature is tombstoned and gone; the + // pre-transaction one survives. + assert!(!result + .score + .time_signatures + .iter() + .any(|t| t.id == ts_tx.id)); + assert!(result + .score + .time_signatures + .iter() + .any(|t| t.id == ts_pre.id)); + assert_eq!(result.score.tempo_map.segments, vec![seg_pre]); + let instance = result + .score + .staff_instances() + .find(|(_, si)| si.id == staff_instance) + .expect("instance survives") + .1 + .clone(); + assert!( + !instance.visible, + "the pre-transaction layout write returns" + ); + assert!(check_invariants(&result.score).is_empty()); +} + +/// A mixed mint + overwrite transaction: both compensation parts compose, and +/// `StrictInverse` refuses the whole undo when *either* part fails while +/// `BestEffort` compensates what it cleanly can. +#[test] +fn mixed_transaction_undo_composes_and_conflicts_by_policy() { + let base = epiphany_core::generators::valid_score(308); + let (staff_instance, target_voice) = target(&base); + let base_instance = base + .staff_instances() + .find(|(_, si)| si.id == staff_instance) + .expect("fixture instance") + .1 + .clone(); + let base_pitch = { + let event = base.events.get(base_instance.voices[0].events[0]).unwrap(); + let mut pitches = Vec::new(); + event.collect_identified_pitches(&mut pitches); + pitches[0].id + }; + let inserted_event = EventId::new(ReplicaId(72), 100); + let tx = TransactionId::from_raw(72); + let tx_ops = |replica: u64| { + chain( + replica, + 10, + Some(tx), + vec![ + OperationKind::DeclareTransaction(TransactionDescriptor { + id: tx, + label: String::from("mixed"), + category: None, + }), + OperationKind::InsertEvent(InsertEventOp { + staff_instance, + event: valuegen::insert_event_value( + EventId::new(ReplicaId(replica), 100), + target_voice, + MusicalPosition(RationalTime::from_int(100)), + MusicalDuration::whole(), + &[PitchId::new(ReplicaId(replica), 101)], + ), + }), + OperationKind::RespellPitch(RespellPitchOp { + pitch: base_pitch, + spelling: valuegen::spelling(2), + }), + ], + ) + }; + + // (A) A superseding respell after the transaction: StrictInverse refuses + // the whole undo — the minted event is NOT tombstoned either. + let mut envelopes = tx_ops(72); + envelopes.push(envelope( + 72, + 3, + 20, + seen(72, 2), + None, + prim(OperationKind::RespellPitch(RespellPitchOp { + pitch: base_pitch, + spelling: valuegen::spelling(4), + })), + )); + let strict = envelope( + 72, + 4, + 30, + seen(72, 3), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::StrictInverse, + }), + ); + envelopes.push(strict.clone()); + let mut set = OperationSet::new(); + set.accept_all(envelopes.clone()); + let result = set.reduce_onto(&base); + assert!(matches!( + effect_for(&result, strict.id), + OperationEffect::Conflicted { .. } + )); + assert!( + result.score.events.contains(inserted_event), + "a refused strict undo tombstones nothing" + ); + assert_eq!( + result.state.spellings.get(&base_pitch), + Some(&valuegen::spelling(4)) + ); + assert!(check_invariants(&result.score).is_empty()); + + // (B) The same set under BestEffort: the mint is tombstoned, the + // superseded spelling is skipped. + let best = envelope( + 72, + 5, + 40, + seen(72, 4), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::BestEffort, + }), + ); + envelopes.push(best.clone()); + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let result = set.reduce_onto(&base); + assert!(matches!( + effect_for(&result, best.id), + OperationEffect::AppliedWithRepair { .. } + )); + assert!(!result.score.events.contains(inserted_event)); + assert_eq!( + result.state.spellings.get(&base_pitch), + Some(&valuegen::spelling(4)), + "the superseding write stands under BestEffort" + ); + assert!(check_invariants(&result.score).is_empty()); +} + +/// Undoing a staff mint refuses while a live (non-member) instance still +/// references the staff (operation_catalog §CreateStaff undo semantics), and +/// `BestEffort` keeps the staff alive rather than stranding the instance. +#[test] +fn undo_of_a_staff_mint_refuses_while_an_instance_references_it() { + let base = epiphany_core::generators::valid_score(309); + let region = base.canvas.regions[0].id; + let instrument = base.instruments[0].id; + let staff_id = StaffId::new(ReplicaId(73), 1); + let instance_id = StaffInstanceId::new(ReplicaId(73), 2); + let tx = TransactionId::from_raw(73); + let mut envelopes = chain( + 73, + 10, + Some(tx), + vec![ + OperationKind::DeclareTransaction(TransactionDescriptor { + id: tx, + label: String::from("staff mint"), + category: None, + }), + OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff(staff_id, instrument), + }), + ], + ); + // A non-member instance referencing the minted staff. + let mut instance_env = envelope( + 73, + 2, + 20, + seen(73, 1), + None, + prim(OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region, + instance: valuegen::staff_instance(instance_id, staff_id), + })), + ); + instance_env.transaction = None; + envelopes.push(instance_env); + let strict = envelope( + 73, + 3, + 30, + seen(73, 2), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::StrictInverse, + }), + ); + let best = envelope( + 73, + 4, + 40, + seen(73, 3), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::BestEffort, + }), + ); + envelopes.push(strict.clone()); + envelopes.push(best.clone()); + + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let result = set.reduce_onto(&base); + + assert!(matches!( + effect_for(&result, strict.id), + OperationEffect::Conflicted { .. } + )); + // BestEffort skips the stranding tombstone: nothing left to compensate, + // but the undo itself applies cleanly with no repairs. + assert_eq!(effect_for(&result, best.id), &OperationEffect::Applied); + assert!(result.score.staves.iter().any(|s| s.id == staff_id)); + assert!(matches!( + result.state.objects.get(&TypedObjectId::Staff(staff_id)), + Some(epiphany_ops::ObjectState::Live) + )); + assert!(check_invariants(&result.score).is_empty()); +} diff --git a/crates/epiphany-render-svg/examples/render_fixture.rs b/crates/epiphany-render-svg/examples/render_fixture.rs index f328b9b..2d879ab 100644 --- a/crates/epiphany-render-svg/examples/render_fixture.rs +++ b/crates/epiphany-render-svg/examples/render_fixture.rs @@ -85,7 +85,10 @@ fn main() -> ExitCode { let config = SolverConfig::default(); let (report, tier) = match solver.as_str() { "stub" => (StubSolver.solve(&constrained, &config), StubSolver.tier()), - "real" => (Engraver.solve(&constrained, &config), Engraver.tier()), + "real" => ( + Engraver::default().solve(&constrained, &config), + Engraver::default().tier(), + ), other => return fail(&format!("unknown solver {other:?}; known: {SOLVERS}")), }; diff --git a/crates/epiphany-render-svg/tests/acceptance.rs b/crates/epiphany-render-svg/tests/acceptance.rs index f5ad078..b5b54f9 100644 --- a/crates/epiphany-render-svg/tests/acceptance.rs +++ b/crates/epiphany-render-svg/tests/acceptance.rs @@ -263,7 +263,7 @@ fn engrave_pipeline(score: &Score) -> (ConstrainedLayoutIR, RenderOutput) { use epiphany_engrave::Engraver; let constrained = to_constrained(&to_logical(score)); - let layout = Engraver + let layout = Engraver::default() .solve(&constrained, &SolverConfig::default()) .layout; let out = render(&layout, &RenderOptions::default()); @@ -285,7 +285,7 @@ fn engraver_output_is_golden_locked_well_formed_with_every_glyph_drawn() { for (fixture, score) in fixtures() { let constrained = to_constrained(&to_logical(&score)); - let report = Engraver.solve(&constrained, &SolverConfig::default()); + let report = Engraver::default().solve(&constrained, &SolverConfig::default()); assert_eq!( report.status, SolveStatus::Solved, diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt index c082312..cca4ab2 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt @@ -2,12 +2,12 @@ fixture=ten_measure_single_staff solver=engrave glyph_count=51 path_count=51 fallback_rect_count=0 -stroke_count=91 -provenance_count=142 +stroke_count=96 +provenance_count=147 layer_count=1 hard_constraint_count=90 xml_well_formed=true -view_box=[-3.059857 -3.632 102.98304 11.024] +view_box=[5.4999995 -26.090391 83.99065 20.589813] class_counts: barline=10 clef=1 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg index e41a03b..e931b2f 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg @@ -1,150 +1,155 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt index ba9c1ea..613aa77 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.snapshot.txt @@ -7,7 +7,7 @@ provenance_count=49 layer_count=1 hard_constraint_count=15 xml_well_formed=true -view_box=[-3.1598568 -3.632 31.588375 11.024] +view_box=[5.4999995 -38.57536 15.298422 33.074783] class_counts: barline=1 clef=3 diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg index 7c71015..61663e7 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg @@ -1,57 +1,57 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index 235cf96..e8b4dff 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -33,17 +33,18 @@ use epiphany_ops::{ AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, ConflictResolutionState, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, - CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, - DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, ExtensionPreconditionId, FieldPath, - HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, IntegrityAnomaly, - IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, ModifyCrossCuttingOp, - ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState, OperationEffect, - OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload, OperationSet, - OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason, - PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult, - RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, - ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, - SerializedCanonicalInputs, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, + CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, + DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, + ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, + IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, + ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, + ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, + OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping, + PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason, + ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord, + ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId, + ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetMetadataOp, + SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload, }; @@ -409,7 +410,7 @@ pub fn conflict_registry(rng: &mut Rng) -> ConflictRegistry { /// A typed precondition failure (every core and registered variant). pub fn precondition_failure_reason(rng: &mut Rng) -> PreconditionFailureReason { - match rng.below(10) { + match rng.below(12) { 0 => PreconditionFailureReason::TargetMissing, 1 => PreconditionFailureReason::TargetTombstoned, 2 => PreconditionFailureReason::WrongRegionTimeModel, @@ -418,7 +419,9 @@ pub fn precondition_failure_reason(rng: &mut Rng) -> PreconditionFailureReason { 5 => PreconditionFailureReason::PositionOutsideRegion, 6 => PreconditionFailureReason::PitchSpaceMismatch, 7 => PreconditionFailureReason::VoiceMissing, - 8 => PreconditionFailureReason::ExtensionPrecondition(ExtensionPreconditionId( + 8 => PreconditionFailureReason::ContainerNotEmpty, + 9 => PreconditionFailureReason::TempoMapMalformed, + 10 => PreconditionFailureReason::ExtensionPrecondition(ExtensionPreconditionId( rng.next_u64() as u128, )), _ => PreconditionFailureReason::Registered(PreconditionFailureRegistryId( @@ -637,7 +640,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP } _ => {} } - let kind = match rng.below(24) { + let kind = match rng.below(28) { 0 => { let pitches = if rng.boolean() { vec![obj_pitch(rng.below(pitches))] @@ -773,6 +776,52 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP ), present: rng.boolean(), }), + // Phase-3 tranche: staff mint, meter/tempo overwrites, layout advisory + // over the shared id space. The signature's value derives from its id + // so an id re-carry is byte-identical (the idempotent mint branch); + // distinct ids give distinct values (the differing-value branch). + 23 => OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff( + StaffId::new(OBJ_REPLICA, rng.below(2)), + InstrumentId::new(OBJ_REPLICA, 0), + ), + }), + 24 => { + let region = RegionId::new(OBJ_REPLICA, rng.below(2)); + let signature = rng.below(2); + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: valuegen::region_start_anchor( + region, + MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 4)), + ), + time_signature: rng.boolean().then(|| { + valuegen::time_signature( + TimeSignatureId::new(OBJ_REPLICA, signature), + signature as u16 + 3, + ) + }), + }) + } + 25 => { + let region = RegionId::new(OBJ_REPLICA, rng.below(2)); + let at = MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 4)); + OperationKind::SetTempoSegment(SetTempoSegmentOp { + region: rng.boolean().then_some(region), + start: valuegen::region_start_anchor(region, at.clone()), + segment: rng.boolean().then(|| { + valuegen::tempo_segment(region, at, 60.0 + rng.below(4) as f64 * 30.0) + }), + }) + } + 26 => OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)), + instrument_override: None, + staff_lines_override: rng + .boolean() + .then(epiphany_core::StaffLineConfiguration::default), + visible: rng.boolean(), + }), _ => OperationKind::Registered( OperationKindRegistryId(rng.next_u64() as u128), rng.byte_vec(0, 16), @@ -831,6 +880,19 @@ impl Session { /// predecessor) with a prior-history DVV (own history always seen; others /// sampled). fn author(&mut self, rng: &mut Rng, r: usize, payload: OperationPayload) { + self.author_tx(rng, r, None, payload); + } + + /// As [`Session::author`], additionally stamping the envelope as a member + /// of `tx`. Same-replica sequential authorship makes each member causally + /// cover its transaction descriptor (the descriptor-precedence rule). + fn author_tx( + &mut self, + rng: &mut Rng, + r: usize, + tx: Option, + payload: OperationPayload, + ) { let n_replicas = self.counters.len(); let replica = ReplicaId(r as u64 + 1); let c = self.counters[r]; @@ -875,7 +937,7 @@ impl Session { author: AuthorId(replica.0 as u128), stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(l), logical), id), causal_context: ctx, - transaction: None, + transaction: tx, payload, }; debug_assert!(epiphany_ops::well_formed(&env).is_ok()); @@ -1122,13 +1184,83 @@ pub fn graph_edit_session( ); } + // Phase-3 tranche: the targeted tx-then-undo flow. One declared transaction + // overwrites LWW keys across the families (spelling, meter, tempo, staff + // layout) and is then undone, so the convergence gates genuinely exercise + // value-restoring undo (a randomly-generated undo almost always hits + // TargetMissing). The overwrite keys (position 400) are disjoint from the + // random-edit keys below, so the undo's verdict — restore vs. superseded — + // stays a pure function of this deterministic seed. + let region = base.canvas.regions[0].id; + let instrument = base.instruments[0].id; + let far = MusicalPosition(RationalTime::from_int(400)); + let undo_tx = TransactionId::new(OBJ_REPLICA, 7001); + session.author( + rng, + 0, + OperationPayload::Primitive(OperationKind::CreateStaff(CreateStaffOp { + staff: valuegen::staff(StaffId::new(OBJ_REPLICA, 7000), instrument), + })), + ); + session.author_tx( + rng, + 0, + Some(undo_tx), + OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor { + id: undo_tx, + label: String::from("tx-then-undo flow"), + category: Some(TransactionCategory::Layout), + })), + ); + for kind in [ + OperationKind::RespellPitch(RespellPitchOp { + pitch: obj_pitch(0), + spelling: valuegen::spelling(6), + }), + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: valuegen::region_start_anchor(region, far.clone()), + time_signature: Some(valuegen::time_signature( + TimeSignatureId::new(OBJ_REPLICA, 7002), + 5, + )), + }), + OperationKind::SetTempoSegment(SetTempoSegmentOp { + region: None, + start: valuegen::region_start_anchor(region, far.clone()), + segment: Some(valuegen::tempo_segment(region, far.clone(), 132.0)), + }), + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: targets[0].0, + instrument_override: None, + staff_lines_override: None, + visible: false, + }), + ] { + session.author_tx(rng, 0, Some(undo_tx), OperationPayload::Primitive(kind)); + } + let policy = if rng.boolean() { + UndoPolicy::StrictInverse + } else { + UndoPolicy::BestEffort + }; + session.author( + rng, + 0, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: undo_tx, + policy, + }), + ); + for _ in 0..80 { let r = rng.below(2) as usize; - // Mix the original edit kinds with the Group-1/2 (M2) ops so the real-Score - // gate exercises their *graph* materialization (reduce_onto + + // Mix the original edit kinds with the Group-1/2 (M2) ops — and the + // Phase-3 meter/tempo/layout overwrites — so the real-Score gate + // exercises their *graph* materialization (reduce_onto + // check_invariants), not just the bookkeeping projection. Each targets a - // live object minted by the phases above. - let kind = match rng.below(9) { + // live object minted by the phases above (or the base region). + let kind = match rng.below(12) { 0 => OperationKind::DeleteEvent(DeleteEventOp { event: obj_event(rng.below(total)), tuplet_compensation: TupletCompensation::NotInTuplet, @@ -1181,7 +1313,7 @@ pub fn graph_edit_session( 7 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp { structure: TypedObjectId::Slur(SlurId::new(OBJ_REPLICA, rng.below(n_slurs))), }), - _ => { + 8 => { let k = rng.below(n_slurs); OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp { // Re-point the slur's end to another even (replica-0) event. @@ -1192,6 +1324,44 @@ pub fn graph_edit_session( )), }) } + // Phase-3 tranche: meter / tempo / layout overwrites on the base + // region, keyed away from the tx-then-undo flow's key (400). The + // signature value derives from its id so an id re-carry is + // byte-identical (mint idempotence) while distinct ids differ. + 9 => { + let signature = rng.below(2); + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: valuegen::region_start_anchor( + region, + MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 8 + 200)), + ), + time_signature: rng.boolean().then(|| { + valuegen::time_signature( + TimeSignatureId::new(OBJ_REPLICA, 7100 + signature), + signature as u16 + 3, + ) + }), + }) + } + 10 => { + let at = MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 8 + 200)); + OperationKind::SetTempoSegment(SetTempoSegmentOp { + region: rng.boolean().then_some(region), + start: valuegen::region_start_anchor(region, at.clone()), + segment: rng.boolean().then(|| { + valuegen::tempo_segment(region, at, 60.0 + rng.below(4) as f64 * 20.0) + }), + }) + } + _ => OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: targets[rng.below(targets.len() as u64) as usize].0, + instrument_override: None, + staff_lines_override: rng + .boolean() + .then(epiphany_core::StaffLineConfiguration::default), + visible: rng.boolean(), + }), }; session.author(rng, r, OperationPayload::Primitive(kind)); } diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 45bf6f6..c718a9b 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -372,6 +372,7 @@ pub fn gen_constrained_layout_ir(rng: &mut Rng) -> ConstrainedLayoutIR { .collect(), vertical_bands: vec![band], constraints: vec![], + break_origins: vec![], engraving_decisions: (0..decisions) .map(|_| gen_engraving_decision(rng)) .collect(), diff --git a/spec/PASS12_BATCH.md b/spec/PASS12_BATCH.md index ea7e65d..f49bb2f 100644 --- a/spec/PASS12_BATCH.md +++ b/spec/PASS12_BATCH.md @@ -57,7 +57,16 @@ code instead is the failure mode this batch exists to prevent. | ~~P12-E2~~ **RESOLVED (Binary Format 0.1.0 §8.2, `req:binfmt:condition-depth`)** | `epiphany-layout-ir` E | The spec places no bound on `BarrierCondition` recursion; the decoder needs one against adversarial bytes. `MAX_CONDITION_DEPTH = 64` implemented — the companion pins 64 as the normative bound (decoders MUST reject deeper; writers MUST NOT emit deeper). | ✅ done | | ~~P12-E3~~ **RESOLVED (Binary Format 0.1.0 §8.1, `req:binfmt:object-kind-open`)** | `epiphany-layout-ir` E | Barrier `ObjectKind` byte form = the `TypedObjectId` 16-bit discriminant (2 LE bytes) with open-value decode (unknown kinds never match, preserving append-only forward compat). Representation and open-value stance ratified. | ✅ done | | P12-E4 | `epiphany-editor-core` E | Barrier matching for operations with no graph target (`SetMetadata`, `DeclareTransaction` — implemented: score-wide barriers only) and for opaque `Registered` operations (implemented: fully conservative match) is unspecified. | G (Ch. 8) | -| P12-E5 | `epiphany-editor-core` E | The unsafe-edit tombstone MUST has no defined mechanism: the manifest-side form (drop declaration + preserved roots? an explicit tombstone record?), interaction with `required = true`, and whether crossing immediately deactivates the extension's remaining barriers (implemented: yes, recorded via `extensions_requiring_tombstone()` for the next bundle write). | G (Ch. 8) | +| P12-E5 | `epiphany-editor-core` E | The unsafe-edit tombstone MUST has no defined mechanism: the manifest-side form (drop declaration + preserved roots? an explicit tombstone record?), interaction with `required = true`, and whether crossing immediately deactivates the extension's remaining barriers (implemented: yes, recorded via `extensions_requiring_tombstone()` for the next bundle write). | G (Ch. 8) || P12-C5 | `epiphany-ops`/`epiphany-core` C/H | Mid-region meter changes: `SetTimeSignature` (catalog §Meter and Tempo Overwrites) reduces a second `MeterChange` into a region's grid cleanly, but the decomposition pre-pass honours only the first governing meter (P12-H4's single-meter simplification), so derived notation ignores the change until multi-meter decomposition lands. Reduction semantics pinned; the derived-annotation gap is H4's. | G / Pass 12 (decomposition) | +| P12-K8 | `epiphany-ops` K | Create score / canvas remain unavailable K1 slots: the document root and canvas are inline singletons (no `TypedObjectId::Canvas`; the root is never op-minted — genesis is `Score::empty` + bundle creation). Turning them into operations needs an addressable root/canvas object model. Decide: define one, or ratify genesis as deliberately outside the operation set. | G (graph model) | +| P12-K9 | `epiphany-ops` K | Differing-value re-creates (a live id re-carried with different content: `CreateStaff`, the carried `TimeSignature`, container creates) refuse with `TargetMissing`, which misnames the situation. Decide whether a dedicated `PreconditionFailureReason` (appended) is warranted. | G / Pass 12 (vocabulary) | +| P12-K10 | `epiphany-ops` K | Undo strand-blocks (StrictInverse refusing to tombstone a minted object still referenced by a live non-member, e.g. a staff with a surviving instance) reuse `ConflictKind::TransactionConflict`. Decide whether undo refusals deserve their own conflict kind. | G / Pass 12 (undo) | +| P12-K11 | `epiphany-ops` K | An undo's value restorations enter the write chains as ordinary writes by the undo op, so a second undo of the same transaction sees the first as a superseding writer (Conflicted/skip) while absence-restorations repeat idempotently — a documented asymmetry. Decide whether chain writes need distinguished undo provenance so repeated undo is uniformly idempotent. | G / Pass 12 (undo) | +| P12-I7 | `epiphany-engrave` I | Page geometry has no graph home: the spec names `Canvas.layout_defaults` ("paper size, margins") but defines no type, and adding a `Canvas` field is a schema-major change under the companion's frozen-layout rule. Casting-off therefore uses an engraver-side `PageGeometry` default (A4 at an 8mm staff: 105x148.5 ss, 7.5 ss margins, documented arithmetic). Define `CanvasLayoutDefaults` with the data-model schema-major; until then solvers MAY default. | G (graph model, schema-major) | +| P12-I8 | `epiphany-engrave` I | Break-constraint satisfaction predicate: implemented as "a `SystemBreakAt`/`PageBreakAt` is satisfied iff the final layout starts a system/page at that slot" (a region-first slot is trivially satisfied). Ch7/Ch9 never define satisfaction for break constraints; ratify the predicate. | G / Pass 12 (solver) | +| P12-I9 | `epiphany-layout-ir` I | Honouring a user break must attribute the decision to its override (`DecisionSource::UserOverride(id)`), but constraints carry no override identity; implemented via a `ConstrainedLayoutIR.break_origins` sidecar populated by `to_constrained`. Bless the sidecar or widen the normalized constraint record. | G / Pass 12 (solver) | +| P12-I10 | `epiphany-layout-ir` I | System-spanning strokes split at system boundaries need synthesized provenance for continuation segments; implemented as `SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS)` with a deterministic `(original, ordinal)` instance key. Add a first-class continuation synthesis kind or bless the registered id. | G / Pass 12 (provenance) | + ## Not yet open elsewhere @@ -65,7 +74,9 @@ Agent I (Track A) has contributed P12-I1..I6. Track B's Agent K has contributed P12-K1..K7; H has contributed P12-H1..H7 (H6/H7 from the 2026-07 spec-compliance audit follow-up, alongside K3/K4). The 2026-07 Push-3 wiring work added C1..C4 (re-anchoring), D1 (bundle operation index), and E1..E5 -(edit barriers). Agent J's Binary Format companion now exists +(edit barriers). The Phase-3 first tranche (casting-off + K1 schema-fill + +value-restoring undo, 2026-07-02) added C5, K8..K11, and I7..I10. +Agent J's Binary Format companion now exists (`spec/binary_format.tex`, v0.1.0): it ratified the P12-D1/E1/E2/E3 inputs (struck through above) and discharged the crates' provisional-codec notes (core P11-4, ops "provisional canonical encoding", bundle P11-D2/D4/D5). Its diff --git a/spec/binary_format.pdf b/spec/binary_format.pdf index cb415b6..ba7c6f1 100644 Binary files a/spec/binary_format.pdf and b/spec/binary_format.pdf differ diff --git a/spec/binary_format.tex b/spec/binary_format.tex index 0aec1d4..cfb90b0 100644 --- a/spec/binary_format.tex +++ b/spec/binary_format.tex @@ -235,7 +235,7 @@ {\Large\scshape\color{epiphanyslate}Binary Format}\\[6pt] {\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt] {\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt] - {\normalsize\color{epiphanyink}Version 0.1.0 --- Phase 2 (canonical wire format: primitives through bundle physical layout + K0 payload framing)}\\[4pt] + {\normalsize\color{epiphanyink}Version 0.2.0 --- Phase 2/3 (canonical wire format: primitives through bundle physical layout + K0 and Phase-3-tranche payload framing)}\\[4pt] {\small\color{epiphanyslate}Normative for the byte layouts it defines} \vfill \end{titlepage} @@ -1287,7 +1287,7 @@ AcceptLoser, \tablenums{1} KeepWinner, \tablenums{2} Override \label{req:binfmt:kind-discriminants} The \texttt{OperationKind} wire discriminant is one byte, golden-locked to the table below. The vocabulary is \textbf{append-only}: new primitive kinds -take discriminants past \tablenums{23}; the assignments below never change. +take discriminants past \tablenums{27}; the assignments below never change. \end{requirement} Each row also pins the payload's byte layout; the field \emph{set and order} @@ -1385,6 +1385,25 @@ sequence sorted ascending by canonical bytes. \texttt{region} (16) \cat{} $\mathrm{lp}$(\texttt{TimeAnchor}) \cat{} \texttt{present} (bool, 1) & \sectionsc{Score Settings} \\ + \tablenums{24} & \texttt{CreateStaff} & + $\mathrm{lp}$(\texttt{Staff}) & + \sectionsc{CreateStaff} \\ + \tablenums{25} & \texttt{SetTimeSignature} & + \texttt{region} (16) \cat{} $\mathrm{lp}$(\texttt{TimeAnchor}) \cat{} + option tag (\tablenums{0}/\tablenums{1}) \cat{} + $\mathrm{lp}$(\texttt{TimeSignature}) if present & + \sectionsc{Meter and Tempo Overwrites} \\ + \tablenums{26} & \texttt{SetTempoSegment} & + option tag \cat{} \texttt{region} (16) if present \cat{} + $\mathrm{lp}$(\texttt{TimeAnchor}) \cat{} option tag \cat{} + $\mathrm{lp}$(\texttt{TempoSegment}) if present & + \sectionsc{Meter and Tempo Overwrites} \\ + \tablenums{27} & \texttt{SetStaffLayout} & + \texttt{staff\_instance} (16) \cat{} option tag \cat{} + \texttt{instrument\_override} (16) if present \cat{} option tag \cat{} + $\mathrm{lp}$(\texttt{StaffLineConfiguration}) if present \cat{} + \texttt{visible} (bool, 1) & + \sectionsc{SetStaffLayout} \\ \bottomrule \end{longtable} \endgroup @@ -1429,7 +1448,7 @@ assigned independently of Section~\ref{sec:ops:kinds}, with both encode and validating decode. One byte; only \texttt{Registered} carries a payload (its \texttt{OperationKindRegistryId}, 16 big-endian bytes, total 17). Unknown discriminants and wrong lengths are decode errors. Append-only past -\tablenums{23}. +\tablenums{27}. \end{requirement} \begingroup\small @@ -1450,15 +1469,19 @@ discriminants and wrong lengths are decode errors. Append-only past \tablenums{9} & \texttt{InsertRegion} & \tablenums{21} & \texttt{DeleteVoice} \\ \tablenums{10} & \texttt{DeleteRegion} & \tablenums{22} & \texttt{SetMetadata} \\ \tablenums{11} & \texttt{InsertStaffInstance} & \tablenums{23} & \texttt{SetMetricGrid} \\ + \tablenums{24} & \texttt{InsertStaff} & \tablenums{25} & \texttt{SetTimeSignature} \\ + \tablenums{26} & \texttt{SetTempoSegment} & \tablenums{27} & \texttt{SetStaffLayout} \\ \bottomrule \end{longtable} \endgroup -\textbf{Naming mapping.} The kind-to-tag projection renames two pairs: +\textbf{Naming mapping.} The kind-to-tag projection renames three pairs: \texttt{OperationKind::\allowbreak CreateRegion} projects to \texttt{OperationKindTag::\allowbreak InsertRegion}, and \texttt{OperationKind::\allowbreak CreateStaffInstance} projects to -\texttt{OperationKindTag::\allowbreak InsertStaffInstance}. The mismatch is +\texttt{OperationKindTag::\allowbreak InsertStaffInstance}, and +\texttt{OperationKind::\allowbreak CreateStaff} projects to +\texttt{OperationKindTag::\allowbreak InsertStaff}. The mismatch is intentional (the tag space predates the M2c naming) and is pinned here so barrier authors target the right tag. @@ -1529,7 +1552,8 @@ trailing bytes are decode errors. \tablenums{6} PitchSpaceMismatch; \tablenums{7} VoiceMissing; \tablenums{8} ExtensionPrecondition \cat{} id (16 BE); \tablenums{9} Registered \cat{} id (16 BE); - \tablenums{10} ContainerNotEmpty. \\ + \tablenums{10} ContainerNotEmpty; + \tablenums{11} TempoMapMalformed. \\ \texttt{RepairRecord} & (struct) \texttt{kind} (\texttt{RepairKind}) \cat{} \texttt{target} (\texttt{TypedObjectId}). \\ @@ -2519,7 +2543,20 @@ layouts of Section~\ref{sec:values:representative}. forms (P12-E1/E2/E3); pins the no-varint rule, the frozen-positional schema-evolution keystone, and the id-leads envelope property; \texttt{SnapshotId} derivation deferred (open question). \\ - \bottomrule + \today & Operation wire forms & 0.2.0 --- Phase-3 first tranche: appended + \texttt{OperationKind} wire discriminants \tablenums{24}--\tablenums{27} + (\texttt{CreateStaff}, \texttt{SetTimeSignature}, + \texttt{SetTempoSegment}, \texttt{SetStaffLayout}) with their payload + layouts, the matching \texttt{OperationKindTag} discriminants + \tablenums{24}--\tablenums{27} (kind-to-tag naming gains + \texttt{CreateStaff}~$\rightarrow$~\texttt{InsertStaff}), and + \texttt{PreconditionFailureReason} \tablenums{11} + (\texttt{TempoMapMalformed}) --- a schema-\emph{minor} evolution under this + document's own append-only rules (Chapter~\ref{ch:evolution}); no existing + assignment changed. Semantics: Operation Catalog 0.5.0 (\sectionsc{CreateStaff}, + \sectionsc{Meter and Tempo Overwrites}, \sectionsc{SetStaffLayout}, and the + value-restoring \sectionsc{UndoTransaction} revision). \\ +\bottomrule \end{longtable} \end{document} diff --git a/spec/core_spec.pdf b/spec/core_spec.pdf index 16e8635..7894d78 100644 Binary files a/spec/core_spec.pdf and b/spec/core_spec.pdf differ diff --git a/spec/core_spec.tex b/spec/core_spec.tex index 93adc50..b6454a2 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -5713,10 +5713,16 @@ pub enum OperationKind { DeleteRegion(DeleteRegionOp), InsertStaffInstance(InsertStaffInstanceOp), DeleteStaffInstance(DeleteStaffInstanceOp), + InsertStaff(InsertStaffOp), + + // Metric-model operations + SetTimeSignature(SetTimeSignatureOp), + SetTempoSegment(SetTempoSegmentOp), // Layout-semantic operations SetUserSystemBreak(SetUserSystemBreakOp), SetUserPageBreak(SetUserPageBreakOp), + SetStaffLayout(SetStaffLayoutOp), // Transaction declaration. Carries metadata; member primitives // reference the transaction by id in their envelopes. @@ -14553,6 +14559,25 @@ layouts they own versus inherit: closing the representational gap that blocked the logical-stage override projection §Engraving Overrides requires. \\ + \today & Phase-3 first tranche (casting-off + catalog) & + Companion movements, no core-spec byte or architecture change. The + Operation Catalog (0.4.0 $\rightarrow$ 0.5.0) gains the Phase-3 + schema-fill tranche --- \texttt{CreateStaff} (set-union global-staff + mint, plus a staff-liveness precondition on + \texttt{CreateStaffInstance}), \texttt{SetTimeSignature} and + \texttt{SetTempoSegment} (LWW structural overwrites beneath the + whole-grid \texttt{SetMetricGrid}), \texttt{SetStaffLayout} (LWW + advisory) --- and a rewritten \sectionsc{UndoTransaction}: + \texttt{StrictInverse}/\texttt{BestEffort} now perform + \emph{value restoration} for overwrite primitives via + canonical-order write chains (delete resurrection, Transpose + inversion, and Cascade closure stay deferred, P11-C8 narrowed). + The Binary Format companion (0.1.0 $\rightarrow$ 0.2.0) appends + the corresponding wire discriminants. This document's + \texttt{OperationKind} listing gains the four kinds. The create + score/canvas slots remain deliberately unavailable pending an + addressable root model (P12-K8). + \\ \bottomrule \end{longtable} diff --git a/spec/operation_catalog.pdf b/spec/operation_catalog.pdf index 430e3a6..93306ce 100644 Binary files a/spec/operation_catalog.pdf and b/spec/operation_catalog.pdf differ diff --git a/spec/operation_catalog.tex b/spec/operation_catalog.tex index 8f82c55..d4d84d2 100644 --- a/spec/operation_catalog.tex +++ b/spec/operation_catalog.tex @@ -226,7 +226,7 @@ {\Large\scshape\color{epiphanyslate}Operation Catalog}\\[6pt] {\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt] {\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt] - {\normalsize\color{epiphanyink}Version 0.4.0 --- Phase 2 (K0 representative + broad-K0 M2 groups)}\\[4pt] + {\normalsize\color{epiphanyink}Version 0.5.0 --- Phase 2/3 (K0 representative + broad-K0 M2 groups + Phase-3 first tranche)}\\[4pt] {\small\color{epiphanyslate}Normative for the operation kinds it defines} \vfill \end{titlepage} @@ -462,8 +462,9 @@ recording the winner (later in canonical order), the loser, and the field \texttt{spelling}. The winner materialises and carries the \texttt{Conflicted} effect tag. -\textbf{Undo semantics.} Undo restores the pre-operation spelling (or removes the -spelling if the operation introduced the first one), under the active policy. +\textbf{Undo semantics.} Value-restoring (Section~\ref{sec:k0:undo}): undo +restores the pre-operation spelling (or removes the spelling if the operation +introduced the first one), under the active policy. \textbf{Re-anchoring.} If the target pitch is tombstoned, the respelling is a no-op (\texttt{TargetTombstoned}). @@ -515,10 +516,10 @@ trimming of a tuplet member remains a later refinement. \emph{differing} values produce a \texttt{StructuralFieldCollision} on the field \texttt{event}, recording the winner (later in canonical order) and the loser. -\textbf{Undo semantics.} Under the prototype's minted-object undo -(Section~\ref{sec:k0:undo}) a field overwrite mints nothing, so undoing the -enclosing transaction does not restore the prior event value; a snapshot-and- -restore inverse is a Phase-3 refinement (P11-C8). +\textbf{Undo semantics.} Value-restoring (Section~\ref{sec:k0:undo}): undoing +the enclosing transaction restores the event's chain-predecessor value, +conflicting (\texttt{StrictInverse}) or skipping (\texttt{BestEffort}) when a +later modification has superseded it. \textbf{Re-anchoring.} If the target event is tombstoned, the modification is a no-op (\texttt{TargetTombstoned}/\texttt{TargetMissing}). @@ -553,11 +554,12 @@ bookkeeping, which tombstones or mints the pitch object either way. is idempotent). Modify: two concurrent differing writes of one pitch produce a \texttt{StructuralFieldCollision} on the field \texttt{pitch}. -\textbf{Undo semantics.} Insert is the only minting member: undoing the enclosing -transaction tombstones the minted pitch, re-resting the event if it was the only -one (Section~\ref{sec:k0:undo}). The prototype's minted-object undo has nothing -to tombstone for delete or modify, so neither is inverted under it (re-introducing -a tombstoned pitch or restoring a prior value is a Phase-3 refinement, P11-C8). +\textbf{Undo semantics.} Insert: undoing the enclosing transaction tombstones +the minted pitch, re-resting the event if it was the only one +(Section~\ref{sec:k0:undo}). Modify: value-restoring +(Section~\ref{sec:k0:undo}) --- the pitch's chain-predecessor value is +restored unless superseded. Delete: still not inverted (re-introducing a +tombstoned pitch is the deferred resurrection case, P11-C8). \textbf{Re-anchoring.} An operation whose target event or pitch is tombstoned is a no-op; tombstoning a pitch runs the cross-cutting re-anchoring table over any @@ -670,9 +672,8 @@ Graph-aware reduction overwrites the structure in place. \textbf{Conflict cases.} Two concurrent differing modifications of one structure produce a \texttt{StructuralFieldCollision} on the field \texttt{cross\_cutting}. -\textbf{Undo semantics.} A modification mints nothing, so the prototype's -minted-object undo (Section~\ref{sec:k0:undo}) does not restore the prior -structure value (a snapshot-and-restore inverse is Phase-3, P11-C8). +\textbf{Undo semantics.} Value-restoring (Section~\ref{sec:k0:undo}): undo +restores the structure's chain-predecessor value unless superseded. \textbf{Re-anchoring.} If the target structure is tombstoned, the modification is a no-op; re-deriving the endpoints lets a subsequent endpoint tombstone re-anchor @@ -754,6 +755,45 @@ re-introduce it (P11-C8). \textbf{Re-anchoring.} Not applicable (the containers are minted/tombstoned by id; the empty-only precondition means a delete never strands live children). +\section{CreateStaff} +\label{sec:k0:create-staff} + +\textbf{Payload schema.} \texttt{CreateStaffOp \{ staff: Staff \}} --- the full +global-staff value (v1): identity, name, abbreviation, instrument reference, +default staff-line configuration, and optional group membership. + +\textbf{Canonical encoding.} The length-framed canonical bytes of +\texttt{staff}. + +\textbf{Reduction rule.} Set-union creation of a global \texttt{Staff} on the +score root, completing the structural-container family +(Section~\ref{sec:k0:structural-containers}) upward: staff \emph{instances} +reference global staves, and until this primitive existed a resolvable staff +could only be base-seeded. A create mints the staff live if its id is fresh; a +repeat create carrying a byte-identical value reduces idempotently, and a +create whose id is already live with a \emph{differing} value is a +precondition no-op. Graph-aware reduction additionally preconditions that the +referenced instrument is live and, when \texttt{group} is present, that the +staff group resolves --- the mint must leave the graph satisfying the +reference-resolution invariants. + +With staves mintable, \texttt{CreateStaffInstance} +(Section~\ref{sec:k0:structural-containers}) preconditions that the +instance's referenced \texttt{Staff} is live (previously the reference was +satisfiable only from the seeded base, so the check was vacuous). + +\textbf{Conflict cases.} None at reduction time (set-union; the differing-value +re-create is a precondition gate, not a conflict). + +\textbf{Undo semantics.} Undo of a create tombstones the minted staff +(Section~\ref{sec:k0:undo}); \texttt{StrictInverse} conflicts if a live staff +instance references it (tombstoning it would strand the instance). + +\textbf{Re-anchoring.} Not applicable (a staff mint references no tombstonable +anchor; there is no \texttt{DeleteStaff} in this catalogue revision --- an +empty-only staff delete mirroring the container discipline is a later +schema-fill). + \section{SetUserSystemBreak} \label{sec:k0:set-user-system-break} @@ -810,15 +850,103 @@ single slot). SetMetricGrid: two concurrent differing grids for one region produce a \texttt{StructuralFieldCollision} on the field \texttt{metric\_grid}. -\textbf{Undo semantics.} All three are field overwrites that mint nothing, so the -prototype's minted-object undo (Section~\ref{sec:k0:undo}) does not restore the -prior metadata, grid, or break preference (a snapshot-and-restore inverse is a -Phase-3 refinement, P11-C8). +\textbf{Undo semantics.} All three are value-restoring field overwrites +(Section~\ref{sec:k0:undo}): undo restores the prior metadata, grid, or break +preference from the key's write chain unless superseded. \textbf{Re-anchoring.} The advisory breaks degrade as for SetUserSystemBreak; the metric grid and metadata are keyed by region / singleton and do not re-anchor (a deleted region's settings are no-ops --- \texttt{TargetMissing}). +\section{Meter and Tempo Overwrites} +\label{sec:k0:meter-tempo} + +\textbf{Payload schema.} The finer-grained metric-model overwrites beneath the +whole-grid \texttt{SetMetricGrid} (Section~\ref{sec:k0:score-settings}): +\texttt{SetTimeSignatureOp \{ region: RegionId, anchor: TimeAnchor, +time\_signature: Option \}} sets, replaces, or (\texttt{None}) +removes the single \texttt{MeterChange} at the anchor's resolved musical +position in the region's default metric grid, carrying the full +\texttt{TimeSignature} value (v1); \texttt{SetTempoSegmentOp \{ region: +Option, start: TimeAnchor, segment: Option \}} sets, +replaces, or removes the single tempo segment starting at the resolved +position, in the score-level tempo map (\texttt{region: None}) or the region's +local map (\texttt{Some}; a set on a region with no local map creates one). + +\textbf{Canonical encoding.} Time signature: \texttt{region}, the +length-framed \texttt{anchor}, then an \texttt{Option} discriminant and (when +present) the length-framed \texttt{time\_signature} value. Tempo segment: an +\texttt{Option} discriminant and (when present) \texttt{region}, then the +length-framed \texttt{start}, then an \texttt{Option} discriminant and (when +present) the length-framed \texttt{segment}. + +\textbf{Reduction rule.} Last-writer-wins structural overwrites keyed by +\texttt{(region, resolved position)} (time signature) and \texttt{(scope, +resolved start)} (tempo segment). A carried \texttt{TimeSignature} is minted +set-union under the same discipline as \texttt{CreateStaff}: fresh id mints; +byte-identical re-carry is idempotent; a differing value under a live id is a +precondition no-op. The time-signature value's beat-group sum is validated at +construction and again at decode, so a malformed value never reaches +reduction. A tempo-segment write preconditions that the \emph{resulting} map +is well-formed (segments ordered and non-overlapping; a non-constant shape +carries its end data; the carried segment's own start equals the operation's +\texttt{start} key) --- a write that would malform the map is refused as a +precondition no-op (\texttt{TempoMapMalformed}). Graph-aware reduction applies +the meter change to \texttt{default\_metric\_grid.meter\_sequence} and the +segment to the scoped tempo map. + +\textbf{Conflict cases.} Two concurrent differing writes of one key produce a +\texttt{StructuralFieldCollision} on the field \texttt{meter\_sequence} or +\texttt{tempo\_segments} respectively, with the standard winner/loser +recording; identical concurrent writes reduce idempotently. + +\textbf{Undo semantics.} Value-restoring per Section~\ref{sec:k0:undo}: undo +restores the key's chain-predecessor value (or its absence). + +\textbf{Re-anchoring.} An event-anchored \texttt{anchor}/\texttt{start} whose +event is later tombstoned degrades by the framework's anchor rules; the +overwrite keys on the \emph{resolved} position, so the recorded change +survives its anchor. + +\begin{openquestion} +\textbf{P12-C5.} A mid-region meter change authored by +\texttt{SetTimeSignature} reduces cleanly and materialises into the grid, but +the notational-decomposition pre-pass currently honours only a region's +\emph{first} governing meter (P12-H4's single-meter simplification), so the +derived notation ignores the change until multi-meter decomposition lands. +The reduction-level semantics are pinned here; the derived-annotation gap is +P12-H4's. +\end{openquestion} + +\section{SetStaffLayout} +\label{sec:k0:set-staff-layout} + +\textbf{Payload schema.} \texttt{SetStaffLayoutOp \{ staff\_instance: +StaffInstanceId, instrument\_override: Option, +staff\_lines\_override: Option, visible: bool \}} --- +the non-break layout advisories with a graph home: the staff instance's three +inline advisory fields, overwritten as a unit. + +\textbf{Canonical encoding.} \texttt{staff\_instance}, an \texttt{Option} +discriminant and (when present) \texttt{instrument\_override}, an +\texttt{Option} discriminant and (when present) the length-framed +\texttt{staff\_lines\_override}, then the boolean. + +\textbf{Reduction rule.} A last-writer-wins \emph{advisory} overwrite keyed by +\texttt{staff\_instance}. Preconditions: the staff instance is live; a present +\texttt{instrument\_override} resolves to a live instrument under graph-aware +reduction. The richer engraving-override vocabulary (stem direction, notehead +shape, custom positions) has no durable graph home yet and remains projected +layout state --- extending this primitive to cover it is staged with the +data-model expansion. + +\textbf{Conflict cases.} None (LWW advisory). + +\textbf{Undo semantics.} Value-restoring per Section~\ref{sec:k0:undo}. + +\textbf{Re-anchoring.} If the staff instance is tombstoned, the overwrite is a +no-op (\texttt{TargetTombstoned}). + \section{DeclareTransaction} \label{sec:k0:declare-transaction} @@ -911,12 +1039,39 @@ policy: UndoPolicy \}}, with \texttt{UndoPolicy} one of \texttt{StrictInverse}, \texttt{BestEffort}, \texttt{Cascade}. Value-complete. \textbf{Reduction rule.} A forward compensating edit computed against the -materialised state at the undo's canonical position (never literal time travel). -The prototype models the compensation as tombstoning the objects the target -transaction minted: \texttt{StrictInverse} conflicts (\texttt{TombstonedTarget}) -if any minted object was already tombstoned; \texttt{BestEffort} tombstones the -survivors; \texttt{Cascade} is \texttt{StrictInverse} over the same set -(dependent-closure undo is a Phase-3 refinement, P11-C8). +materialised state at the undo's canonical position (never literal time +travel). The compensation has two parts. + +\emph{Minted-object tombstoning} (as before): every object the target +transaction minted is tombstoned. \texttt{StrictInverse} conflicts +(\texttt{TombstonedTarget}) if any minted object was already tombstoned; +\texttt{BestEffort} tombstones the survivors. + +\emph{Value restoration} (this revision): for every last-writer-wins overwrite +the target transaction performed --- event and identified-pitch modification, +respelling, cross-cutting modification, metadata, metric grid, meter change, +tempo segment, staff layout, and the user break advisories --- the reducer +maintains, per overwritten key, the \emph{canonical-order write chain} of +(writer, value) pairs. Undoing the transaction restores each written key to +its chain-predecessor value (or its absence, where the transaction introduced +the first value), \emph{provided the transaction's write is still the key's +last writer}. Because the chain is keyed by canonical order, the restored +value is a pure function of the operation set: permutation-invariant by +construction. When a causally-later or canonically-later write has +superseded the key, \texttt{StrictInverse} refuses the whole undo with a +\texttt{TransactionConflict} conflict naming the undo and the superseding +writer; \texttt{BestEffort} restores the still-last-written keys and skips +the superseded ones. Restorations are expressed in the effect status (a fully +clean compensation is \texttt{Applied}; a mixed one is +\texttt{AppliedWithRepair} carrying only the tombstone repairs) --- no new +repair vocabulary. + +\emph{Still deferred} (P11-C8, narrowed): re-introducing content tombstoned by +\emph{delete} primitives (a deterministic resurrection needs a system-derived +identifier derivation the ratified closed tag set does not yet include); +\texttt{Transpose} inversion (interval algebra, P12-K2); and +\texttt{Cascade}'s dependent-closure computation --- \texttt{Cascade} remains +\texttt{StrictInverse} over the same set. % =========================================================================== \chapter{v0 \texorpdfstring{$\rightarrow$}{->} v1 Payload Migration} @@ -987,21 +1142,26 @@ a fresh design. structural metric grid, advisory page break). \end{description} -\section*{Remaining framework slots (Phase 3 --- unavailable, MUST reject)} +\section*{Implemented in the Phase-3 first tranche (now in Chapter~\ref{ch:k0})} \begin{description} - \item[Create score / canvas / staff] - The remaining structural mints (the document root, the canvas, and global - staves) the Phase-2 slice does not exercise. Discipline: set-union creation. - \item[Set time signature / tempo segment] - The finer-grained metric-model overwrites beneath the whole-grid - \texttt{SetMetricGrid} (Section~\ref{sec:k0:score-settings}): a single meter - change or tempo segment rather than the region's entire grid. Discipline: - last-writer-wins structural overwrite. - \item[Set layout] - The non-break layout advisories (the page/system-break advisories themselves - are implemented --- Sections~\ref{sec:k0:set-user-system-break} and - \ref{sec:k0:score-settings}). Discipline: LWW advisory. + \item[Create staff; set time signature / tempo segment; set layout] + Sections~\ref{sec:k0:create-staff}, \ref{sec:k0:meter-tempo}, and + \ref{sec:k0:set-staff-layout}. The disciplines are as drafted: set-union + creation, LWW structural overwrite, and LWW advisory respectively. +\end{description} + +\section*{Remaining framework slots (unavailable, MUST reject)} + +\begin{description} + \item[Create score / canvas] + The document root and the canvas are \emph{inline singletons}, not + id-addressed objects: \texttt{TypedObjectId} has no Canvas kind, and the + root is never op-minted --- genesis today is the empty-document + constructor plus bundle creation, outside the operation set. Turning these + into operations requires an addressable root/canvas object model (a + graph-model decision for G, filed as a Pass-12 row), so the slots remain + deliberately unavailable rather than force-designed. \end{description} \begin{nongoal}