#![forbid(unsafe_code)]
//! # epiphany-editor-core
//!
//! A **headless editor core** over an Epiphany score: the API a GUI calls to drive
//! the editing loop, with no UI and no rendering backend of its own. It packages
//! the proven vertical slice — hit-test → score object → operation → reduce →
//! re-layout → re-render → re-resolve selection — behind [`EditorSession`].
//!
//! It owns:
//!
//! * **selection state** ([`Selection`]) — the score object and the stable layout
//!   id that anchors it across relayouts;
//! * the current **render and hit-test query** ([`EditorSession::render`],
//!   [`EditorSession::hit_test`], [`EditorSession::click`]);
//! * **operation minting** ([`EditorSession::apply`] and intents like
//!   [`EditorSession::transpose_selection`]) — the caller supplies an
//!   [`OperationKind`] or an intent, and the session assembles the
//!   [`OperationEnvelope`] (id, author, stamp, and a causal context covering the
//!   session's prior edits) so a GUI never hand-rolls the envelope bookkeeping and
//!   so sequential edits to one target read as overwrites, not concurrent conflicts;
//! * **atomic transactions** ([`EditorSession::apply_transaction`]) — several
//!   primitives committed together under one `DeclareTransaction`, applied
//!   all-or-nothing by the reducer and reconstructed as one unit by a peer; the
//!   substrate for intents that must land a value change and its matching respelling
//!   together (and a cleaner unit of work for undo);
//! * **apply / re-render** — reduce the operation onto the score, re-render, and
//!   refuse a diagnostic-only (non-renderable) layout, leaving the document
//!   unchanged if the edit would not render;
//! * **selection preservation** — re-resolve the selection against the new layout,
//!   keeping it when its layout object survives (the cursor does not jump off the
//!   edited object) and clearing it when the object is gone;
//! * **undo / redo** ([`EditorSession::undo`], [`EditorSession::redo`]) — re-reducing
//!   the active prefix of the op log without (or with) its last unit, so even a
//!   delete is undone (the CRDT is delete-wins, so a tombstone is re-reduced *away*
//!   rather than inverted);
//! * two views of the op log: [`EditorSession::applied_operations`] (the
//!   currently-applied prefix, which shrinks on undo) and
//!   [`EditorSession::authored_operations`] (every envelope ever minted, the
//!   **append-only**, monotonic-id record). The authored log is a *local* high-water
//!   source, not streaming-consistent undo — a peer replaying it would re-apply
//!   undone units (undo is a local prefix change, not an operation).
//!
//! The session is **solver-agnostic**: it holds a `Box<dyn ConstraintSolver>`, so a
//! GUI plugs in the real `Engraver`, the stub, or any conformant solver. It
//! produces a [`RenderIR`]; turning that into pixels is the renderer's job.

mod barriers;
mod fragment;

pub use barriers::ActiveExtension;
pub use fragment::{
    FragmentError, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_EVENTS, MAX_FRAGMENT_NESTING_DEPTH,
};

use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;

use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile};
use epiphany_core::{
    AcousticPitch, AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId,
    EventPosition, IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
    PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId,
    RegionTimeModel, ReplicaId, ScalePosition, Score, Slur, SlurId, SpellingDirective,
    SpellingNominal, SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId,
    StemConfiguration, Tie, TieId, TimeSignature, TimeSignatureDisplay, TransactionId,
    TranspositionInterval, TuningReference, TupletId, TypedObjectId, VoiceId, WallClockTime,
};
use epiphany_layout_ir::{
    active_clef_or, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical,
    to_render, BoundingBox, ConstraintSolver, ExtensionRef, HitTestMap, LayoutContent,
    LayoutObjectId, LogicalLayoutIR, Point, Rect, RenderIR, ResolvedLayoutIR, ResolvedSystem,
    SolverConfig, TimePoint,
};
use epiphany_ops::{
    advisory_violations, AcceptOutcome, AuthorId, CausalContext, CreateCrossCuttingOp,
    CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp,
    InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope,
    OperationKind, OperationKindTag, OperationPayload, OperationSet, OperationStamp,
    RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeIntervalOp,
    TupletCompensation,
};

/// The current selection: the score-graph object to act on, plus the stable layout
/// object id that anchors it across relayouts (so it survives an edit's re-render).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct Selection {
    /// The score-graph object selected (what an operation targets).
    pub source: TypedObjectId,
    /// The layout object the selection is anchored on — content-independent, so it
    /// survives a relayout of an unchanged source.
    pub layout_object: LayoutObjectId,
}

/// The session's selection: a **set** of [`Selection`] members plus one member
/// marked the **anchor** — the reference point single-target intents act on
/// ([`EditorSession::alter_selection`] on a single member,
/// [`EditorSession::move_selection_staff_step`], [`EditorSession::add_note_to_selection`],
/// [`EditorSession::insert_note_after_selection`],
/// [`EditorSession::set_selection_duration`]), and, later, where a paste lands.
///
/// * [`EditorSession::click`] / [`EditorSession::select`] ([`Self::replace_single`])
///   replace the whole set with one member — today's single-selection behavior,
///   unchanged.
/// * [`EditorSession::select_within`] ([`Self::replace_members`]) seeds the set from
///   the hit-test map's own **paint order** (back-to-front, matching
///   [`HitTestMap::within`]), one score object per member (see
///   [`EditorSession::select_within`] for how several primitives sharing one
///   `source` — e.g. a note and its ledger lines — collapse to one member); the
///   anchor is the first member painted.
/// * [`EditorSession::toggle_at`] ([`Self::toggle`]) adds or removes one member,
///   keeping the rest in their existing relative order; the anchor becomes the
///   member just added.
///
/// **Anchor-fallback rule** (shared by [`Self::toggle`] removing the anchor and
/// [`Self::reresolve`] dropping it across a relayout): the anchor falls to the
/// member now occupying its old position in the set's order — i.e. the member that
/// was right after it — wrapping to the first member if the anchor was last (or
/// only), or to `None` if the set is now empty. Anchor identity is tracked by
/// [`LayoutObjectId`] rather than duplicated as a `Selection` copy, so it can never
/// desync from a member's refreshed `source` after a relayout — [`Self::anchor`]
/// always looks it up in `members`.
#[derive(Clone, Default, Debug, PartialEq)]
struct SelectionSet {
    /// Members in the set's current order (paint order when built by
    /// [`Self::replace_from_paint_order`]; add order otherwise — see the type doc).
    members: Vec<Selection>,
    /// The anchor member's layout-object identity. `Some` only when that id names an
    /// entry in `members` — every mutator below preserves this invariant.
    anchor: Option<LayoutObjectId>,
}

impl SelectionSet {
    /// The anchor member, if any.
    fn anchor(&self) -> Option<&Selection> {
        let id = self.anchor?;
        self.members.iter().find(|m| m.layout_object == id)
    }

    /// Empties the set.
    fn clear(&mut self) {
        self.members.clear();
        self.anchor = None;
    }

    /// Replaces the set with a single member, or clears it — [`EditorSession::click`]
    /// and [`EditorSession::select`]'s shared behavior.
    fn replace_single(&mut self, selection: Option<Selection>) {
        match selection {
            Some(sel) => {
                self.members = vec![sel];
                self.anchor = Some(sel.layout_object);
            }
            None => self.clear(),
        }
    }

    /// Replaces the set with `members` verbatim (already deduped and ordered by the
    /// caller — see [`EditorSession::select_within`]). Anchor = the first member.
    fn replace_members(&mut self, members: Vec<Selection>) {
        self.anchor = members.first().map(|s| s.layout_object);
        self.members = members;
    }

    /// Adds `candidate` if its layout object is absent (anchor becomes it), or
    /// removes it if present (see the type doc's anchor-fallback rule for what the
    /// anchor becomes when the removed member was the anchor).
    fn toggle(&mut self, candidate: Selection) {
        match self
            .members
            .iter()
            .position(|m| m.layout_object == candidate.layout_object)
        {
            Some(pos) => {
                self.members.remove(pos);
                if self.anchor == Some(candidate.layout_object) {
                    self.anchor = self
                        .members
                        .get(pos)
                        .or_else(|| self.members.first())
                        .map(|m| m.layout_object);
                }
            }
            None => {
                self.members.push(candidate);
                self.anchor = Some(candidate.layout_object);
            }
        }
    }

    /// Re-resolves the whole set against a fresh hit-test map: a member survives
    /// (its `source` refreshed from the new region) if its layout object is still
    /// present there, keeping the set's existing relative order (a stable filter —
    /// the order was already meaningful, and filtering preserves it); a vanished
    /// member drops. If the anchor's own member dropped, the anchor follows the type
    /// doc's fallback rule, computed over the **surviving** members in their
    /// original positions (so "the next member" skips other members that also
    /// dropped). Returns whether the anchor's own member survived unchanged — the
    /// single-selection `EditOutcome::selection_preserved` this generalizes.
    fn reresolve(&mut self, map: &HitTestMap) -> bool {
        let anchor_pos = self
            .anchor
            .and_then(|id| self.members.iter().position(|m| m.layout_object == id));
        let mut survivors: Vec<(usize, Selection)> = Vec::with_capacity(self.members.len());
        for (i, member) in self.members.iter().enumerate() {
            if let Some(region) = map
                .regions
                .iter()
                .find(|r| r.layout_object == member.layout_object)
            {
                survivors.push((
                    i,
                    Selection {
                        source: region.source,
                        layout_object: member.layout_object,
                    },
                ));
            }
        }
        let anchor_survived = anchor_pos.is_some_and(|p| survivors.iter().any(|(i, _)| *i == p));
        self.anchor = if anchor_survived {
            self.anchor
        } else if let Some(p) = anchor_pos {
            survivors
                .iter()
                .find(|(i, _)| *i > p)
                .or_else(|| survivors.first())
                .map(|(_, s)| s.layout_object)
        } else {
            None
        };
        self.members = survivors.into_iter().map(|(_, s)| s).collect();
        anchor_survived
    }
}

/// What a click at a world point resolves to **vertically**: the staff under the
/// cursor and the natural diatonic pitch at that height under the staff's clef. The
/// vertical half of click-to-insert (the horizontal half — the musical position — is
/// a separate query). The accidental is left natural; a caller respells if needed.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct StaffPitch {
    /// The staff instance the click is over (nearest staff).
    pub staff_instance: StaffInstanceId,
    /// The diatonic nominal at the clicked height.
    pub nominal: CmnNominal,
    /// The octave (scientific-pitch) at the clicked height.
    pub octave: i8,
}

/// The beat grid a click-to-insert snaps to: the musical-time `step` between insert
/// positions (and the natural default written duration of a note entered there) —
/// a DAW's "grid" setting. The caller chooses the resolution; deriving a default
/// from the region's meter is a later refinement, so this carries no meter logic.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct GridResolution {
    /// The grid step from the region origin; insert positions are its multiples.
    pub step: MusicalDuration,
}

impl GridResolution {
    /// A quarter-note grid — a sensible default for a client that has not chosen a
    /// resolution (a meter-derived default is a later refinement).
    pub fn quarter() -> Self {
        // `1/4` is always representable (a non-zero denominator).
        GridResolution {
            step: MusicalDuration(RationalTime::new(1, 4).expect("1/4 is a valid duration")),
        }
    }
}

/// What a click at a world point resolves to **horizontally**: the metric region
/// under the cursor and the grid-snapped musical position to insert at. The vertical
/// half (the pitch) is a [`StaffPitch`]; a click-to-insert combines the two.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct GridPosition {
    /// The metric region the position belongs to.
    pub region: RegionId,
    /// The grid-snapped onset to insert at.
    pub position: MusicalPosition,
}

/// The note-entry caret: **session-local** editing-cursor state, never recorded
/// in the op log (contract: `spec/CONTRACT_EDITOR_T3_CARET.md` §W1 — undo does
/// not move it; `DECISIONS.md`). `(voice, position, entry_duration)`: the voice
/// and musical position [`EditorSession::enter_nominal`] /
/// [`EditorSession::enter_pitch`] / [`EditorSession::enter_rest`] insert at next,
/// and the written duration they insert with (also the step
/// [`EditorSession::advance`] / [`EditorSession::retreat`] move by).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Caret {
    /// The voice the caret inserts into.
    pub voice: VoiceId,
    /// The musical position the caret inserts at.
    pub position: MusicalPosition,
    /// The written duration the caret inserts with (and steps by).
    pub entry_duration: MusicalDuration,
}

/// What an [`EditorSession::apply`] did.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct EditOutcome {
    /// The operation changed the score graph.
    pub graph_changed: bool,
    /// Whether the **anchor** member survived the relayout unchanged (its layout
    /// object still exists) — the reference point single-target intents act on.
    /// `false` if the anchor's own object is gone, even when the anchor then fell to
    /// a different surviving member of the set (the anchor-fallback rule, applied
    /// across a relayout), or the set emptied.
    pub selection_preserved: bool,
}

/// A cross-cutting structure [`EditorSession::copy_selection`] could not
/// carry into the fragment because only one of its two endpoints was inside
/// the selection (Ruling E closure v1: "fail closed, report dropped"). The
/// item itself — a slur or a tie fully inside the selection instead copies
/// into the fragment text; it is never named here.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum DroppedItem {
    /// A slur cut by the selection boundary, named by its source id (for
    /// diagnostics only — the id never appears in the fragment text).
    Slur(SlurId),
    /// A tie cut by the selection boundary.
    Tie(TieId),
}

/// What [`EditorSession::copy_selection`] produced.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CopyOutcome {
    /// The fragment text (`(epiphany-fragment (0 1 0) …)`, Ruling E) — pass
    /// this to [`EditorSession::paste_at`] / [`EditorSession::paste_over_selection`].
    pub fragment: String,
    /// Every slur/tie closure v1 dropped because only one endpoint was
    /// inside the selection — the "report dropped" channel.
    pub dropped: Vec<DroppedItem>,
    /// How many events made it into the fragment (summed over every voice
    /// lane) — a GUI-facing count (T2 W4b) so a caller can report "N
    /// event(s) copied" without decoding the fragment text itself (the
    /// fragment grammar/decoder are crate-private; this is the supported
    /// way to learn what a copy actually captured).
    pub events_copied: usize,
}

/// What a [`EditorSession::paste_at`] / [`EditorSession::paste_over_selection`]
/// inserted.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct PasteOutcome {
    /// The underlying `apply`/`apply_transaction` outcome the paste's single
    /// atomic transaction produced.
    pub outcome: EditOutcome,
    /// How many fragment events were inserted (one
    /// [`OperationKind::InsertEvent`] each).
    pub events_inserted: usize,
    /// How many fragment slurs were re-minted as fresh
    /// [`OperationKind::CreateCrossCutting`] operations (closing the W4a
    /// follow-up, `DECISIONS.md` 2026-07-24): every fragment slur, once both
    /// its endpoints resolve to freshly-minted destination events, is
    /// replayed as a **brand-new** slur — a fresh [`SlurId`], never the
    /// source's.
    pub slurs_inserted: usize,
    /// How many fragment ties were re-minted the same way. A replayed tie's
    /// `pitch_pairing` is always `None`: the fragment format never carries
    /// the source's explicit pairing (Ruling E: no object ids — pairing keys
    /// on source `PitchId`s), so a replayed tie always falls back to the
    /// reducer's implicit enharmonic-pairing-by-ascending-`PitchId` rule
    /// (Chapter 5 §"Ties"; `DECISIONS.md` documents this is not a
    /// structural blocker — the reducer accepts `pitch_pairing: None`
    /// unconditionally).
    pub ties_inserted: usize,
}

/// An editing error. None of these mutate the session.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum EditorError {
    /// The solver returned a diagnostic-only (non-renderable) layout — the edit is
    /// rejected and the document is left unchanged.
    NotRenderable,
    /// The edit was not applied and nothing changed: either a minted operation was
    /// not well-formed (e.g. minted under the reserved
    /// [`epiphany_core::ReplicaId::SYSTEM_DERIVED`] identity), or the reduction was
    /// not clean — an atomic transaction whose member preconditions failed rolls back
    /// as a conflict, and the editor refuses to log a transaction that did not take
    /// effect. The edit is dropped rather than silently no-op'd.
    RejectedOperation,
    /// [`EditorSession::apply_transaction`] was called with no member operations,
    /// which would log only a no-op transaction descriptor.
    EmptyTransaction,
    /// An [`OperationKind::DeclareTransaction`] was submitted as an edit — passed to
    /// [`EditorSession::apply`], or as a member of [`EditorSession::apply_transaction`].
    /// The session declares transactions itself; only primitive mutations may be
    /// submitted, so a directly-applied or nested declaration (which would log a dead
    /// descriptor-only unit) is refused.
    DeclareTransactionNotAllowed,
    /// An intent needed a selection but none is set.
    NoSelection,
    /// The selection is not the kind the intent requires (e.g. a transpose needs a
    /// pitch selection).
    WrongSelection {
        /// The kind of object the intent expected.
        expected: &'static str,
    },
    /// An intent needed the note-entry **caret** ([`EditorSession::enter_nominal`],
    /// [`EditorSession::enter_pitch`], [`EditorSession::enter_rest`],
    /// [`EditorSession::advance`], [`EditorSession::retreat`],
    /// [`EditorSession::set_entry_duration`]) but none is set
    /// ([`EditorSession::set_caret_at`] places one). A caret and a selection are
    /// independent session-local cursors (contract:
    /// `spec/CONTRACT_EDITOR_T3_CARET.md` §W1), so this is its own variant rather
    /// than an overload of [`NoSelection`](EditorError::NoSelection) — flagged as
    /// an underdetermined call in `DECISIONS.md`.
    NoCaret,
    /// An insert-after would land on a musical position already occupied by another
    /// event in the same voice (the reducer would silently no-op it). The edit is
    /// refused; inserting into a packed voice needs an explicit make-room policy.
    InsertSlotOccupied,
    /// A click-to-insert resolved to no insert target: the point is off any staff, the
    /// region is non-metric or has too few rendered events to place a position, or the
    /// staff has no voice / no diatonic clef. Nothing is inserted.
    NoInsertTarget,
    /// A make-room overwrite could not treat an overlapped tuplet atomically. Tuplets are
    /// atomic: a pencil overwrite touching any member of a *flat* tuplet cascades the whole
    /// tuplet away (every member tombstoned, structure removed), freeing its span for the
    /// new note. This error is raised only when that cascade is not available — the
    /// overlapped member belongs to a **nested** tuplet (parent or child), whose ratio
    /// arithmetic a flat cascade would misstate. Also raised by a *resize*
    /// ([`set_selection_duration`](EditorSession::set_selection_duration)) of a tuplet
    /// member itself: in-place rescaling of a member is a later refinement.
    OverlapsTuplet,
    /// A duration edit was given a non-positive duration, which is not a valid written
    /// note value. Nothing changes.
    InvalidDuration,
    /// A duration change (a resize, or a make-room trim/split) would alter an event that
    /// carries a persistent **decomposition attachment** — its notated components would
    /// no longer sum to the event's duration (invariant 15). Editing decompositions is a
    /// later refinement, so the edit is refused rather than left inconsistent. (A delete
    /// is fine; the tombstoned target's decomposition is no longer checked.)
    DecomposedEvent,
    /// The operation failed an advisory precondition against the current materialized
    /// score (Chapter 6 §"Validation Modes"). The session is **authoring mode**: all
    /// preconditions are enforced, and advisory failures refuse the edit *before an
    /// envelope is minted* — nothing enters the op log, and a peer never sees the
    /// operation. (Replay/remote reduction enforces only invariant preconditions, so
    /// the same envelope, had it been minted elsewhere, would reduce cleanly.)
    AdvisoryViolation {
        /// Every advisory precondition the operation failed.
        violations: Vec<epiphany_ops::AdvisoryViolation>,
    },
    /// The edit matches an active edit barrier (Chapter 8 §"Behavior Under
    /// Unknown Extensions": *edits MUST be checked against every active edit
    /// barrier; a match is prohibited unless the user explicitly performs an
    /// unsafe edit*). Nothing is minted and nothing changes. Crossing the
    /// barrier deliberately is [`EditorSession::apply_unsafe`] /
    /// [`EditorSession::apply_transaction_unsafe`], which acknowledges the loss
    /// of the named extension's data.
    BarrierProhibited {
        /// The extension whose edit barrier prohibits the edit.
        extension: ExtensionRef,
        /// The prohibited operation class.
        operation: OperationKindTag,
    },
    // --- Fragment (copy/paste) errors: Ruling E, `spec/PLAN_EDITOR_APP.md`. ---
    /// [`EditorSession::copy_selection`] refuses: the selection covers some,
    /// but not all, of a live tuplet's members. Closure v1's own refusal
    /// discipline (the reducer's own atomicity, applied at clipboard scale)
    /// — a tuplet is atomic, so a fragment cannot represent part of one.
    /// Nothing is minted; the selection is unchanged.
    PartialTupletSelection {
        /// The tuplet only partially covered by the selection.
        tuplet: TupletId,
    },
    /// A pasted fragment's clipboard text was malformed, oversized, or an
    /// unsupported version (Ruling E: "Fragments arrive from the OS
    /// clipboard; they are input, not trusted state"). Nothing changes.
    InvalidFragment(fragment::FragmentError),
    /// A fragment names more voice **lanes** than the paste destination's
    /// staff instance has voices to receive them. Ruling E names the
    /// destination staff/voice but not a multi-lane mapping policy; this
    /// session resolves it positionally — fragment lane `i` pastes into the
    /// destination staff instance's `i`-th voice — and refuses rather than
    /// guessing when the destination falls short (flagged in the W4a
    /// report as a resolved ambiguity).
    InsufficientVoicesForFragment {
        /// How many voice lanes the fragment names.
        needed: usize,
        /// How many voices the destination staff instance actually has.
        available: usize,
    },
    /// A pasted fragment decoded cleanly but named no events to insert
    /// (every voice lane was empty). Nothing changes. A fragment produced by
    /// [`EditorSession::copy_selection`] never has this shape (a copy always
    /// requires a non-empty selection); this guards a hand-crafted or
    /// corrupt fragment.
    EmptyFragment,
}

impl fmt::Display for EditorError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EditorError::NotRenderable => {
                f.write_str("the resulting layout is diagnostic-only and cannot be rendered")
            }
            EditorError::RejectedOperation => f.write_str(
                "the edit was rejected and nothing changed (an ill-formed operation, or a \
                 transaction that rolled back)",
            ),
            EditorError::NoSelection => f.write_str("no selection"),
            EditorError::WrongSelection { expected } => {
                write!(f, "the selection is not a {expected}")
            }
            EditorError::NoCaret => f.write_str("no caret is set"),
            EditorError::InsertSlotOccupied => {
                f.write_str("the position after the selection is already occupied in its voice")
            }
            EditorError::EmptyTransaction => {
                f.write_str("a transaction must have at least one member operation")
            }
            EditorError::DeclareTransactionNotAllowed => {
                f.write_str("a transaction cannot be declared through an edit; the session manages transactions")
            }
            EditorError::NoInsertTarget => {
                f.write_str("the click resolved to no insert target (off-staff, non-metric, or no voice)")
            }
            EditorError::OverlapsTuplet => {
                f.write_str("the edit would have to alter a nested tuplet, or resize a tuplet member")
            }
            EditorError::InvalidDuration => f.write_str("a non-positive duration is not a note value"),
            EditorError::DecomposedEvent => {
                f.write_str("the edit would change the duration of an event with a decomposition")
            }
            EditorError::AdvisoryViolation { violations } => {
                write!(
                    f,
                    "the edit failed {} advisory precondition(s) (authoring mode): {violations:?}",
                    violations.len()
                )
            }
            EditorError::BarrierProhibited {
                extension,
                operation,
            } => {
                write!(
                    f,
                    "the edit ({operation:?}) is prohibited by an edit barrier declared by \
                     extension {extension:?}; only an explicit unsafe edit may cross it \
                     (tombstoning that extension's data)"
                )
            }
            EditorError::PartialTupletSelection { tuplet } => write!(
                f,
                "the selection covers only some members of tuplet {tuplet:?}; a tuplet is \
                 atomic and cannot be partially copied"
            ),
            EditorError::InvalidFragment(err) => write!(f, "invalid clipboard fragment: {err}"),
            EditorError::InsufficientVoicesForFragment { needed, available } => write!(
                f,
                "the fragment names {needed} voice lane(s) but the destination staff instance \
                 has only {available}"
            ),
            EditorError::EmptyFragment => {
                f.write_str("the fragment names no events to paste")
            }
        }
    }
}

impl std::error::Error for EditorError {}

impl From<fragment::FragmentError> for EditorError {
    fn from(err: fragment::FragmentError) -> Self {
        EditorError::InvalidFragment(err)
    }
}

/// A headless editor session over a score. A GUI opens one, queries its render and
/// hit-test map to draw and to resolve clicks, and drives edits through it.
pub struct EditorSession {
    // The pristine open-time score. Every edit reduces the whole applied-op log
    // onto this base, so the session's materialization is exactly the canonical
    // reduction of the operations it emits — which also means a new op's causal
    // predecessors are present in the set being reduced (see `apply`).
    base: Score,
    score: Score,
    solver: Box<dyn ConstraintSolver>,
    // The solved layout the current render derives from — the input a renderer (e.g.
    // epiphany-render-svg) consumes. Kept alongside the RenderIR, which is the
    // hit-test projection of it.
    resolved: ResolvedLayoutIR,
    render: RenderIR,
    map: HitTestMap,
    // The clef in force at the start of each manifested staff, resolved by time from
    // the logical layout's `PlacedClef`s (not vector order). Keyed by the manifesting
    // `(region, staff)`, since one staff can be tiled into several regions. The
    // vertical half of click-to-insert reads this to spell the clicked height.
    start_clefs: BTreeMap<(RegionId, StaffId), Clef>,
    selection: SelectionSet,
    // The note-entry caret (contract: `spec/CONTRACT_EDITOR_T3_CARET.md` §W1) —
    // session-local, never in the op log. `reresolve_caret` (called alongside
    // `reresolve_selection` after every apply/undo/redo) clears it when its voice
    // no longer exists; nothing else moves it except `advance`/`retreat` and a
    // successful entry (which advances it by the entry duration).
    caret: Option<Caret>,
    // Operation-minting identity. A real client supplies its own replica/author.
    // Minted operations form this replica's monotonic local history; the next op's
    // counter is `authored.len()` (never reused across undo), so a failed apply consumes
    // no id. The causal context covers the active prefix, not a fixed counter range, so a
    // fork (undo + new edit) does not strand a later op pending behind a removed unit.
    replica: ReplicaId,
    author: AuthorId,
    // The currently-applied envelopes, in order — the log the session re-reduces onto
    // `base` to materialize `score`. One user action appends one unit (a primitive is
    // one envelope; a transaction is its descriptor plus members). Undo moves the last
    // unit to `redo_stack` and re-reduces the shorter prefix; a new edit clears the redo
    // stack. (A delete tombstones permanently in the CRDT, so undo cannot invert it —
    // it re-reduces *without* the delete instead, which is why the log drives the score
    // rather than a stack of inverse operations.)
    applied: Vec<OperationEnvelope>,
    // The size (envelope count) of each applied unit, in order, so undo can drop a whole
    // unit at once. `undo_units.iter().sum() == applied.len()`.
    undo_units: Vec<usize>,
    // Units undone and available to redo, most-recently-undone last (LIFO). Cleared by a
    // new edit; a redo re-appends the top unit and re-reduces.
    redo_stack: Vec<Vec<OperationEnvelope>>,
    // The append-only record of *every* envelope this session has ever minted — active,
    // undone, and forked-away. It never shrinks, so it is the monotonic high-water source
    // for new operation, event, pitch, and transaction ids: undoing an edit must not let
    // a later edit re-mint its ids (which would equivocate a streamed op or collide an
    // entity id). A new op's counter is `authored.len()`; minting scans this, not the
    // active prefix. (`applied` is always a — possibly non-contiguous, after a fork —
    // subsequence of `authored`.)
    authored: Vec<OperationEnvelope>,
    // The active extension declarations whose edit barriers gate edits (Chapter 8
    // §"Behavior Under Unknown Extensions"). Injected via `set_active_extensions`
    // (the session opens on a bare `Score`, so it never reads a manifest itself);
    // empty means no barriers, i.e. every edit passes the gate.
    active_extensions: Vec<ActiveExtension>,
    // Extensions crossed by an unsafe edit. Per the spec, an unsafe edit MUST
    // tombstone the crossed extension's chunks; the session has no bundle
    // plumbing, so it records the obligation here (see
    // `extensions_requiring_tombstone`) and deactivates the extension's barriers.
    pending_extension_tombstones: BTreeSet<ExtensionRef>,
}

/// One materialization of the op log: the reduced score and everything derived from it
/// that the session installs together (so the render, hit-test, and clef table never
/// disagree with the score). Produced by [`EditorSession::materialize`].
struct Materialization {
    score: Score,
    start_clefs: BTreeMap<(RegionId, StaffId), Clef>,
    resolved: ResolvedLayoutIR,
    render: RenderIR,
    map: HitTestMap,
}

impl EditorSession {
    /// Opens a session on `score` with `solver`, rendering immediately. Errors with
    /// [`EditorError::NotRenderable`] if the initial layout is diagnostic-only.
    pub fn open(score: Score, solver: Box<dyn ConstraintSolver>) -> Result<Self, EditorError> {
        let (start_clefs, resolved, render, map) =
            render_score(&score, solver.as_ref()).ok_or(EditorError::NotRenderable)?;
        Ok(EditorSession {
            base: score.clone(),
            score,
            solver,
            resolved,
            render,
            map,
            start_clefs,
            selection: SelectionSet::default(),
            caret: None,
            replica: ReplicaId(1),
            author: AuthorId(0),
            applied: Vec::new(),
            undo_units: Vec::new(),
            redo_stack: Vec::new(),
            authored: Vec::new(),
            active_extensions: Vec::new(),
            pending_extension_tombstones: BTreeSet::new(),
        })
    }

    /// Overrides the replica/author the session mints operations under (a GUI sets
    /// these to the local editing identity). Defaults to `ReplicaId(1)` / author 0.
    ///
    /// **Pre-edit only** — panics if called after any edit (including ones since undone).
    /// A session's op ids are one replica's monotonic history; switching identity
    /// mid-stream would continue the counter under a new replica, leaving a
    /// `(new_replica, 0)` hole that the missing-predecessor rule would hold pending. The
    /// guard is on the **authored** history (every id ever minted), not the active
    /// prefix, so undoing back to an empty prefix does not reopen it.
    pub fn with_identity(mut self, replica: ReplicaId, author: AuthorId) -> Self {
        assert!(
            self.authored.is_empty(),
            "with_identity must be set before any edit: a session's op log is a \
             single replica's monotonic history"
        );
        self.replica = replica;
        self.author = author;
        self
    }

    /// The current document.
    pub fn score(&self) -> &Score {
        &self.score
    }

    /// The current resolved layout — the input a renderer (e.g. `epiphany-render-svg`)
    /// consumes to draw the score. The [`Self::render`] / [`Self::hit_test`] views are
    /// its hit-test projection.
    pub fn resolved(&self) -> &ResolvedLayoutIR {
        &self.resolved
    }

    /// The current render, for the GUI to draw.
    pub fn render(&self) -> &RenderIR {
        &self.render
    }

    /// The current hit-test map, for the GUI to resolve clicks and drags.
    pub fn hit_test(&self) -> &HitTestMap {
        &self.map
    }

    /// The current selection set, in the set's current order (paint order when
    /// built by [`Self::select_within`], add order otherwise — see
    /// [`Self::select_within`] and [`Self::toggle_at`]).
    pub fn selections(&self) -> &[Selection] {
        &self.selection.members
    }

    /// The anchor member — the reference point single-target intents act on.
    /// `None` iff the set is empty.
    pub fn anchor(&self) -> Option<&Selection> {
        self.selection.anchor()
    }

    /// The anchor, if any. A compatibility accessor for callers that only ever dealt
    /// with a single selection: with at most one member, the anchor *is* "the
    /// selection", so this falls out of [`Self::anchor`] directly.
    pub fn selection(&self) -> Option<Selection> {
        self.selection.anchor().copied()
    }

    /// The operations **currently applied**, oldest first — the active prefix the
    /// materialized score reduces from. It grows by one unit per successful edit and
    /// **shrinks on [`undo`](Self::undo)** (a redo re-extends it), so it is not an
    /// append-only history; the session keeps that separately as the monotonic id
    /// source. A rejected or non-renderable edit leaves it untouched.
    pub fn applied_operations(&self) -> &[OperationEnvelope] {
        &self.applied
    }

    /// Every envelope this session has ever minted, in mint order — the **append-only**
    /// authored history, including units since undone or forked away by a later edit.
    /// This is the monotonic high-water source that keeps ids unique; the currently
    /// applied subset is [`applied_operations`](Self::applied_operations).
    pub fn authored_operations(&self) -> &[OperationEnvelope] {
        &self.authored
    }

    /// The most recently applied operation, if any (the tail of
    /// [`Self::applied_operations`]).
    pub fn last_applied(&self) -> Option<&OperationEnvelope> {
        self.applied.last()
    }

    /// Installs the active extension set whose edit barriers gate subsequent
    /// edits (Chapter 8 §"Behavior Under Unknown Extensions": edits MUST be
    /// checked against every active edit barrier). Replaces any previous set.
    ///
    /// The session opens on a bare [`Score`], not a bundle, so it never reads a
    /// manifest itself: whoever opened the bundle decodes each declaration's
    /// `edit_barriers` blob ([`epiphany_layout_ir::decode_edit_barriers`]) and
    /// injects the result here. A session with no active extensions (the
    /// default) has no barriers, and every edit passes the gate.
    pub fn set_active_extensions(&mut self, extensions: Vec<ActiveExtension>) {
        self.active_extensions = extensions;
    }

    /// The currently active extension declarations (barriers included). An
    /// extension crossed by an unsafe edit is removed from this set — its data
    /// is bound for tombstoning, so its barriers no longer bind.
    pub fn active_extensions(&self) -> &[ActiveExtension] {
        &self.active_extensions
    }

    /// Extensions crossed by an unsafe edit in this session, whose chunks MUST
    /// be tombstoned per the spec (§"Behavior Under Unknown Extensions": *the
    /// unsafe-edit operation MUST tombstone the relevant extension chunks (so
    /// they are no longer preserved) rather than silently breaking extension
    /// invariants*). The session has no bundle plumbing of its own, so it
    /// records the obligation: the next bundle write reads this set and drops
    /// the named extensions' declarations (and with them their
    /// `preserved_chunk_roots`) from the manifest it commits.
    pub fn extensions_requiring_tombstone(&self) -> &BTreeSet<ExtensionRef> {
        &self.pending_extension_tombstones
    }

    /// The first active-barrier match for `kind`, as the refusal `apply` /
    /// `apply_transaction` surface (spec: a matching edit *is prohibited*).
    fn barrier_refusal(&self, kind: &OperationKind) -> Option<EditorError> {
        self.crossed_extensions(kind)
            .into_iter()
            .next()
            .map(|extension| EditorError::BarrierProhibited {
                extension,
                operation: kind.tag(),
            })
    }

    /// Every active extension with a barrier prohibiting `kind` against the
    /// current materialized score — the extensions an unsafe edit crosses.
    fn crossed_extensions(&self, kind: &OperationKind) -> Vec<ExtensionRef> {
        if self.active_extensions.is_empty() {
            return Vec::new();
        }
        let subjects = barriers::subjects_of(kind, &self.score);
        barriers::prohibiting_extensions(
            &self.active_extensions,
            kind.tag(),
            &subjects,
            &barriers::ScoreOracle(&self.score),
        )
    }

    /// Records that an unsafe edit crossed `extension`: its chunks are now
    /// bound for tombstoning ([`Self::extensions_requiring_tombstone`]), and —
    /// since its data will no longer be preserved — its barriers no longer
    /// bind, so the declaration leaves the active set.
    fn record_unsafe_crossing(&mut self, extension: ExtensionRef) {
        self.pending_extension_tombstones.insert(extension);
        self.active_extensions.retain(|e| e.extension != extension);
    }

    /// The staff and diatonic pitch at a world `point` — the **vertical half** of a
    /// click-to-insert. Finds the staff the click is over (the nearest staff by its
    /// rendered line band) and the natural pitch at that height under the staff's
    /// clef. `None` if there is no staff, the staff has no diatonic clef (percussion),
    /// or the height is out of representable range. The horizontal half (the musical
    /// position to insert at) is a separate query.
    pub fn staff_pitch_at(&self, point: Point) -> Option<StaffPitch> {
        let (region, si, origin) = self.nearest_manifestation(point)?;
        // The clef in force at the staff start, resolved by time when the layout was
        // built (a mid-staff clef change is a later refinement); default treble when
        // none is declared.
        let clef = self
            .start_clefs
            .get(&(region, si.staff))
            .copied()
            .unwrap_or_default();
        // Each step is half a staff space, +y up; round to the nearest line/space.
        let step = ((point.y.0 - origin) * 2.0).round() as i32;
        let (nominal, octave) = staff_step_pitch(step, &clef)?;
        Some(StaffPitch {
            staff_instance: si.id,
            nominal,
            octave,
        })
    }

    /// The manifested staff a world `point` is nearest — its `(region, staff
    /// instance)` and the staff's step-origin `y`. Both halves of click-to-insert —
    /// [`Self::staff_pitch_at`] (pitch) and [`Self::position_at`] (position) —
    /// select the staff/region through this. `None` on a non-finite point or no
    /// staff line.
    ///
    /// When the layout carries real cast-off page geometry, the staff is resolved
    /// *within the system under the click* ([`Self::system_manifestation`]):
    /// casting-off splits a staff's lines per system, and only the first segment
    /// keeps the manifestation `stable_id`, so the flat scan below would always
    /// answer with system 1's origin/span. Without cast geometry (a solver that
    /// does not cast off, e.g. the stub), the flat scan is the whole story:
    /// horizontal span first (which region — one staff tiles across regions that
    /// can share a y band), then the vertical band (which staff within it). The
    /// bottom staff line carries the staff's manifestation id as its stroke
    /// `stable_id`, which is how a rendered line maps back to
    /// `(region, staff_instance)`.
    fn nearest_manifestation(&self, point: Point) -> Option<(RegionId, &StaffInstance, f32)> {
        // Reject a non-finite click up front: `dist_to_band`'s `<`/`>` would let a
        // NaN fall through as distance 0 (matching every staff), and downstream a
        // `round() as i32` would saturate a non-finite height into a bogus step. A
        // malformed view transform yields no manifestation rather than an arbitrary one.
        if !point.x.0.is_finite() || !point.y.0.is_finite() {
            return None;
        }
        if let Some(found) = self.system_manifestation(point) {
            return Some(found);
        }
        let mut best: Option<(RegionId, &StaffInstance, f32)> = None;
        let mut best_dist = (f32::INFINITY, f32::INFINITY);
        for (region, si) in self.score.staff_instances() {
            let manifest = manifestation_layout_id(&TypedObjectId::Staff(si.staff), region);
            let Some(bottom) = self
                .resolved
                .strokes
                .iter()
                .find(|s| s.provenance.stable_id == manifest)
            else {
                continue;
            };
            // The bottom line is horizontal; its `y` is the step origin and its
            // endpoints bound the staff's horizontal extent.
            let origin = bottom.from.y.0;
            let span = (
                bottom.from.x.0.min(bottom.to.x.0),
                bottom.from.x.0.max(bottom.to.x.0),
            );
            let dist = (
                dist_to_band(point.x.0, span),
                dist_to_band(point.y.0, (origin, origin + STAFF_SPAN)),
            );
            if dist < best_dist {
                best_dist = dist;
                best = Some((region, si, origin));
            }
        }
        best
    }

    /// The manifested staff under `point`, resolved through the cast-off page tree
    /// — the multi-system path of [`Self::nearest_manifestation`]. Finds the system
    /// under the click ([`Self::containing_system`]), reads the region it manifests
    /// from its provenance, picks the vertically nearest staff band among the
    /// system's staff records, and recovers that staff's step origin **in this
    /// system**. `None` when no system carries real geometry (the caller then falls
    /// back to the flat stroke scan), or when the containing system carries no
    /// usable staff record.
    fn system_manifestation(&self, point: Point) -> Option<(RegionId, &StaffInstance, f32)> {
        let system = self.containing_system(point)?;
        // A system manifests one region, and carries it as its provenance source
        // whether it is the region's first system (the region's own provenance) or
        // a later one (synthesized under `EngravedBreak` *from the region*) — read
        // the identity from the data rather than assuming which system this is.
        let TypedObjectId::Region(region) = system.provenance.source else {
            return None;
        };
        // The nearest staff band vertically: within one system the region is fixed,
        // and its staves are stacked in disjoint y bands, so — unlike the flat
        // scan, where x picks the region first — the vertical distance alone is
        // the discriminator.
        let staff = system
            .staves
            .iter()
            .filter(|s| rect_is_real(&s.bounding_box))
            .min_by(|a, b| {
                let da = dist_to_band(point.y.0, rect_y_band(&a.bounding_box));
                let db = dist_to_band(point.y.0, rect_y_band(&b.bounding_box));
                da.total_cmp(&db)
            })?;
        // The staff record's provenance is its bottom-most rendered line *in this
        // system* (`build_system` in the engraver's casting pass); that stroke's
        // height is the exact step origin the pitch math expects. Fall back to
        // deriving it from the staff's box, whose vertical extent is the 5-line
        // span padded by the line half-thickness on both sides — the bottom line
        // sits half the (span + padding) height above the box bottom, minus half
        // the span.
        let origin = self
            .resolved
            .strokes
            .iter()
            .find(|s| s.provenance.stable_id == staff.provenance.stable_id)
            .map(|s| s.from.y.0)
            .unwrap_or_else(|| {
                let b = &staff.bounding_box;
                b.origin.y.0 + b.size.height.0 / 2.0 - STAFF_SPAN / 2.0
            });
        let si = self
            .score
            .staff_instances()
            .find(|(r, si)| *r == region && si.staff == staff.staff)
            .map(|(_, si)| si)?;
        Some((region, si, origin))
    }

    /// The cast-off system whose bounding box contains `point`, or — when the
    /// point is in the gutter between systems — the **nearest system by vertical
    /// distance**: systems on a page all start at the left margin, so they overlap
    /// in x and are disjoint in y, making the y band the discriminator (and a
    /// click slightly above/below a system still resolves, mirroring the flat
    /// path's nearest-staff tolerance). Only a system with real (non-degenerate)
    /// geometry is a candidate: a solver that does not cast off (the stub) emits
    /// zero-size boxes, and those must not capture clicks — `None` sends the
    /// caller down the flat single-system path unchanged.
    fn containing_system(&self, point: Point) -> Option<&ResolvedSystem> {
        if !point.x.0.is_finite() || !point.y.0.is_finite() {
            return None;
        }
        let mut nearest: Option<&ResolvedSystem> = None;
        let mut nearest_dy = f32::INFINITY;
        for system in self.resolved.pages.iter().flat_map(|p| p.systems.iter()) {
            let bounds = &system.bounding_box;
            if !rect_is_real(bounds) {
                continue;
            }
            if rect_contains(bounds, point) {
                return Some(system);
            }
            let dy = dist_to_band(point.y.0, rect_y_band(bounds));
            // Strict `<`: on a tie, the earlier system in page/reading order wins
            // (deterministic, and the gutter midpoint resolves upward).
            if dy < nearest_dy {
                nearest_dy = dy;
                nearest = Some(system);
            }
        }
        nearest
    }

    /// The musical position a world `point` snaps to on the beat grid — the
    /// **horizontal half** of a click-to-insert. Finds the metric region under the
    /// cursor, inverts the click's `x` to a raw musical position (piecewise-linear
    /// through the region's rendered event anchors), then snaps it to `grid`. `None`
    /// if the click is off any staff, the region is non-metric (a proportional or
    /// aleatoric region has no musical position to land on), `grid` is non-positive,
    /// or there are fewer than two rendered metric events to fix a scale from.
    /// The vertical half (the pitch) is [`Self::staff_pitch_at`].
    ///
    /// In a cast-off multi-system layout the inverse works **within the system
    /// under the click**: each system restarts at the page's left margin, so one x
    /// names a different time on each system. A click right of a system's last
    /// anchor extrapolates that system's end segment (the empty staff after its
    /// last note — the same end-extrapolation as the flat layout, and it may name
    /// a time that *renders* on the next system: the result is a musical position,
    /// not a system-local one); a click left of its first anchor extrapolates
    /// backward and clamps at the region origin; and a system rendering fewer than
    /// two of the region's anchors yields `None`, the per-system reading of the
    /// two-anchor rule above.
    pub fn position_at(&self, point: Point, grid: &GridResolution) -> Option<GridPosition> {
        if !grid.step.is_positive() {
            return None;
        }
        let (region, _, _) = self.nearest_manifestation(point)?;
        // Only a metric region has a musical position to land on (a proportional or
        // aleatoric region is measured in wall-clock / DAG order, not metric onsets).
        if !self.region_is_metric(region) {
            return None;
        }
        // Constrain the anchors to the system under the click: casting-off bakes
        // every system back to the left margin, so the region-wide anchor list is
        // x-non-monotonic in time, and inverting through it would map a later
        // system's click onto the first system's times. Without cast geometry
        // (`containing_system` is `None` — the stub) the whole region is one flat
        // monotonic run, unchanged.
        let system_box = self.containing_system(point).map(|s| s.bounding_box);
        // Two anchors fix the x→time scale; with fewer, the spacing density is
        // unknown, so there is nothing to extrapolate an empty-space position from.
        let anchors = self.position_anchors(region, system_box.as_ref());
        if anchors.len() < 2 {
            return None;
        }
        let raw = invert_x(&anchors, point.x.0);
        let position = snap_to_grid(raw, &grid.step);
        Some(GridPosition { region, position })
    }

    /// A sensible [`GridResolution`] for a click at `point`, derived from the meter of
    /// the region under it (its governing time signature's beat — `1/denominator`),
    /// defaulting to a quarter when there is no determinable meter. A GUI uses this so
    /// the pencil snaps to the score's beat instead of a fixed value. `None` only when
    /// the point resolves to no staff (a non-finite point, or nothing rendered).
    pub fn default_grid_at(&self, point: Point) -> Option<GridResolution> {
        let (region, _, _) = self.nearest_manifestation(point)?;
        Some(self.region_default_grid(region))
    }

    /// The current note-entry caret, if any (contract:
    /// `spec/CONTRACT_EDITOR_T3_CARET.md` §W1). `None` until [`Self::set_caret_at`]
    /// places one, or after [`Self::clear_caret`] or a voice-vanish re-resolution
    /// (checked after every apply/undo/redo — see `DECISIONS.md`).
    pub fn caret(&self) -> Option<Caret> {
        self.caret.clone()
    }

    /// Clears the caret (no note-entry cursor).
    pub fn clear_caret(&mut self) {
        self.caret = None;
    }

    /// Places the caret at a world `point` — the same staff/region resolution
    /// [`Self::insert_note_at`] uses (its private `nearest_manifestation`), taking the
    /// staff instance's **primary voice** (or its first, mirroring
    /// [`Self::insert_note_at`]'s own voice choice), the musical position snapped
    /// via [`Self::default_grid_at`] + [`Self::position_at`], and the entry
    /// duration initialized from that grid's step. Errors with
    /// [`EditorError::NoInsertTarget`] under the same conditions
    /// [`Self::insert_note_at`] does (off any staff, a non-metric region, or too
    /// few rendered events to place a position).
    pub fn set_caret_at(&mut self, point: Point) -> Result<Caret, EditorError> {
        let (region, voice) = {
            let (region, si, _origin) = self
                .nearest_manifestation(point)
                .ok_or(EditorError::NoInsertTarget)?;
            let voice = si
                .voices
                .iter()
                .find(|v| v.is_primary)
                .or_else(|| si.voices.first())
                .ok_or(EditorError::NoInsertTarget)?;
            (region, voice.id)
        };
        let grid = self
            .default_grid_at(point)
            .ok_or(EditorError::NoInsertTarget)?;
        let placed = self
            .position_at(point, &grid)
            .ok_or(EditorError::NoInsertTarget)?;
        if placed.region != region {
            return Err(EditorError::NoInsertTarget);
        }
        let caret = Caret {
            voice,
            position: placed.position,
            entry_duration: grid.step,
        };
        self.caret = Some(caret.clone());
        Ok(caret)
    }

    /// Sets the caret's entry duration — the written value
    /// [`Self::enter_nominal`]/[`Self::enter_pitch`]/[`Self::enter_rest`] insert,
    /// and the step [`Self::advance`]/[`Self::retreat`] move by. The duration is
    /// checked first, independent of caret state (the same order
    /// [`Self::set_selection_duration`] uses): [`EditorError::InvalidDuration`]
    /// for a non-positive duration, else [`EditorError::NoCaret`] if no caret is
    /// set.
    pub fn set_entry_duration(&mut self, duration: MusicalDuration) -> Result<(), EditorError> {
        if !duration.is_positive() {
            return Err(EditorError::InvalidDuration);
        }
        let caret = self.caret.as_mut().ok_or(EditorError::NoCaret)?;
        caret.entry_duration = duration;
        Ok(())
    }

    /// Moves the caret forward by its entry duration. No insertion. Positions are
    /// global rationals, so this crosses barlines with no special casing. Errors
    /// with [`EditorError::NoCaret`] if no caret is set.
    pub fn advance(&mut self) -> Result<Caret, EditorError> {
        let caret = self.caret.as_mut().ok_or(EditorError::NoCaret)?;
        caret.position = caret.position.clone() + caret.entry_duration.clone();
        Ok(caret.clone())
    }

    /// Moves the caret backward by its entry duration, clamped at position zero
    /// (the region origin) — never negative. No insertion. Errors with
    /// [`EditorError::NoCaret`] if no caret is set.
    pub fn retreat(&mut self) -> Result<Caret, EditorError> {
        let caret = self.caret.as_mut().ok_or(EditorError::NoCaret)?;
        caret.position = retreat_position(&caret.position, &caret.entry_duration);
        Ok(caret.clone())
    }

    /// The meter-derived default [`GridResolution`] for `region`: the beat (`1/
    /// denominator`) of its governing time signature — the first one a region measure
    /// references, else the score's first — and a quarter note when none is determinable
    /// (or the meter has no single denominator). Mirrors the prepass's single-governing-
    /// meter-per-region resolution (`resolve_measure_units`).
    fn region_default_grid(&self, region: RegionId) -> GridResolution {
        let governing = self
            .score
            .canvas
            .regions
            .iter()
            .find(|r| r.id == region)
            .and_then(|graph_region| {
                graph_region
                    .staff_instances()
                    .iter()
                    .flat_map(|si| si.measures.iter())
                    .filter_map(|measure| measure.time_signature.as_ref())
                    .find_map(|tsid| self.score.time_signatures.iter().find(|ts| &ts.id == tsid))
                    .or_else(|| self.score.time_signatures.first())
            });
        match governing.and_then(time_signature_beat) {
            Some(step) => GridResolution { step },
            None => GridResolution::quarter(),
        }
    }

    /// Whether `region` uses metric time (positions are musical onsets).
    fn region_is_metric(&self, region: RegionId) -> bool {
        self.score
            .canvas
            .regions
            .iter()
            .find(|r| r.id == region)
            .is_some_and(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
    }

    /// The `(musical onset, leftmost rendered x)` anchors of `region`'s metric events,
    /// in ascending time order — the samples the horizontal inverse interpolates. A
    /// glyph maps to its onset through its `Pitch`/`Event` provenance source; the
    /// leftmost glyph at an onset (the notehead/stem column) fixes that onset's x.
    ///
    /// With `within` (a cast-off system's bounding box), only glyphs positioned
    /// inside that box are sampled: casting-off restarts every system at the left
    /// margin, so the region-wide list is x-non-monotonic in time, and the inverse
    /// must see a single system's monotonic run. `None` samples the whole region —
    /// the flat single-system behavior.
    fn position_anchors(
        &self,
        region: RegionId,
        within: Option<&Rect>,
    ) -> Vec<(MusicalPosition, f32)> {
        // Source id (event or one of its pitches) → the event's metric onset.
        let mut onset: HashMap<TypedObjectId, MusicalPosition> = HashMap::new();
        let mut pitches: Vec<&IdentifiedPitch> = Vec::new();
        for (rid, _, voice) in self.score.voices() {
            if rid != region {
                continue;
            }
            for event_id in &voice.events {
                let Some(event) = self.score.events.get(*event_id) else {
                    continue;
                };
                let EventPosition::Musical(at) = event.position() else {
                    continue;
                };
                onset.insert(TypedObjectId::Event(*event_id), at.clone());
                pitches.clear();
                event.collect_identified_pitches(&mut pitches);
                for ip in &pitches {
                    onset.insert(TypedObjectId::Pitch(ip.id), at.clone());
                }
            }
        }
        // Leftmost glyph x per onset, gathered in time order (BTreeMap key = onset).
        let mut by_onset: BTreeMap<MusicalPosition, f32> = BTreeMap::new();
        for glyph in &self.resolved.glyphs {
            // Only the directly-manifested onset glyph (the notehead/rest) fixes the
            // onset's x. Skip synthesized glyphs: an accidental shares its note's
            // `Pitch` source but is placed *left* of the notehead, so taking it as the
            // anchor would pull the onset's x off the time column.
            if glyph.provenance.synthesis.is_some() {
                continue;
            }
            // Constrain to the requested system's box: a glyph on another system
            // must not contribute an anchor to this system's monotonic run.
            if within.is_some_and(|bounds| !rect_contains(bounds, glyph.position)) {
                continue;
            }
            if let Some(at) = onset.get(&glyph.provenance.source) {
                let x = glyph.position.x.0;
                by_onset
                    .entry(at.clone())
                    .and_modify(|cur| *cur = cur.min(x))
                    .or_insert(x);
            }
        }
        by_onset.into_iter().collect()
    }

    /// Resolves a click at a world `point`: selects the **topmost** hit there (what
    /// a GUI selects) as the whole set's sole member, or clears the selection if the
    /// point hits nothing. Returns the new (single) selection.
    pub fn click(&mut self, point: Point) -> Option<Selection> {
        let hit = self.map.hit(point).into_iter().next().map(|r| Selection {
            source: r.source,
            layout_object: r.layout_object,
        });
        self.selection.replace_single(hit);
        hit
    }

    /// Selects a layout object by id (a programmatic / restored selection), if it is
    /// present in the current layout, as the whole set's sole member. Returns the
    /// new (single) selection.
    pub fn select(&mut self, layout_object: LayoutObjectId) -> Option<Selection> {
        let hit = self
            .map
            .regions
            .iter()
            .find(|r| r.layout_object == layout_object)
            .map(|r| Selection {
                source: r.source,
                layout_object,
            });
        self.selection.replace_single(hit);
        hit
    }

    /// Adds or removes the **topmost** hit at `point` (the same query [`Self::click`]
    /// uses) to/from the selection set — a modifier click (Ctrl/Cmd-click). The
    /// anchor becomes the member just added; removing the anchor falls it to the
    /// member now at its old position (wrapping to the first member if it was
    /// last), the same anchor-fallback rule a relayout uses. A point that hits
    /// nothing leaves the set unchanged — unlike [`Self::click`], a modifier click
    /// over empty space is not a deselect-all gesture. Returns the resulting set.
    pub fn toggle_at(&mut self, point: Point) -> &[Selection] {
        let hit = self.map.hit(point).into_iter().next().map(|r| Selection {
            source: r.source,
            layout_object: r.layout_object,
        });
        if let Some(candidate) = hit {
            self.selection.toggle(candidate);
        }
        &self.selection.members
    }

    /// Selects every hit-test region under `rect` (a rubber-band drag) as the whole
    /// set, in the hit-test map's own **paint order** ([`HitTestMap::within`],
    /// ascending — back to front), **one member per score object**: several
    /// primitives can share one `source` (a note's ledger lines are strokes
    /// synthesized *from* its `Pitch` — see [`epiphany_layout_ir::SynthesisKind`] —
    /// so a note needing ledger lines manifests as the ledger strokes *and* its own
    /// notehead glyph, all sourced from the same pitch), and those must count as one
    /// selected object, not several. Each group keeps the position of its
    /// **first-painted** occurrence (so the group's overall paint order is right
    /// even when its representative primitive isn't the first one seen — ledgers are
    /// strokes and paint before glyphs at the same layer, so a ledgered note's group
    /// is very often first-seen via a ledger stroke); the group's `layout_object` is
    /// its **non-synthesized** occurrence when one is present (the note's own
    /// manifestation, not an ancillary mark whose layout object can vanish on a
    /// relayout that keeps the note but drops its need for that mark — tracking a
    /// synthesized id there would wrongly drop the member). The anchor is the first
    /// member painted. `rect`'s corners are order-independent (normalized internally
    /// before querying), so a drag in any direction works. Returns the resulting
    /// set.
    pub fn select_within(&mut self, rect: BoundingBox) -> &[Selection] {
        let normalized = BoundingBox::new(
            rect.left.0.min(rect.right.0),
            rect.bottom.0.min(rect.top.0),
            rect.left.0.max(rect.right.0),
            rect.bottom.0.max(rect.top.0),
        );
        let mut members: Vec<Selection> = Vec::new();
        for region in self.map.within(normalized) {
            match members.iter_mut().find(|m| m.source == region.source) {
                Some(existing) => {
                    if region.synthesis.is_none() {
                        existing.layout_object = region.layout_object;
                    }
                }
                None => members.push(Selection {
                    source: region.source,
                    layout_object: region.layout_object,
                }),
            }
        }
        self.selection.replace_members(members);
        &self.selection.members
    }

    /// Clears the selection set.
    pub fn clear_selection(&mut self) {
        self.selection.clear();
    }

    /// Builds an [`OperationEnvelope`] at operation `counter` under the session's
    /// identity, carrying `payload`, an optional `transaction` membership, and the
    /// `causal_context` the caller derived from the active head — the bookkeeping a GUI
    /// would otherwise assemble by hand. Pure: it reads no mutable state, so a failed
    /// apply consumes no id.
    fn envelope_at(
        &self,
        counter: u64,
        payload: OperationPayload,
        transaction: Option<TransactionId>,
        causal_context: CausalContext,
    ) -> OperationEnvelope {
        let id = OperationId::new(self.replica, counter);
        OperationEnvelope {
            id,
            author: self.author,
            stamp: OperationStamp::new(
                HybridLogicalClock::new(WallClockTime(counter as i64), 0),
                id,
            ),
            causal_context,
            transaction,
            payload,
        }
    }

    /// The causal context a newly-minted op carries: it covers every **currently
    /// applied** op (so two sequential same-target edits read as intentional overwrites,
    /// the later covering the earlier, rather than concurrent conflicts). Derived from
    /// the active head's own context plus the head, so it covers the whole active prefix
    /// without asserting coverage of any unit since undone or forked away — which would
    /// otherwise leave the new op held pending behind a missing predecessor. Empty when
    /// nothing is applied (a root op). The compact contiguous form is preserved while
    /// the active prefix has no holes (the common case); a fork records the active tail
    /// as dots (see [`extend_context`]).
    fn active_prior_context(&self) -> CausalContext {
        match self.applied.last() {
            None => CausalContext::new(),
            Some(head) => extend_context(head.causal_context.clone(), head.id),
        }
    }

    /// Accepts `new` envelopes on top of the prior log, reduces the whole set onto
    /// the pristine open-time `base`, re-renders, and re-resolves the selection,
    /// committing only if every step succeeds. **Atomic**: on a rejected (not
    /// well-formed) envelope or a diagnostic-only layout, nothing mutates — the op
    /// log included. The shared engine of [`Self::apply`] and
    /// [`Self::apply_transaction`].
    ///
    /// Reducing the *accumulated* set onto `base` (rather than the new ops alone onto
    /// the running materialization) is what lets each envelope carry a causal context
    /// covering its predecessors: those predecessors are present in the set, so the
    /// missing-predecessor rule does not hold a new op pending, and the session's
    /// render is exactly the canonical reduction of the op log it emits. (Re-reducing
    /// the whole log each edit is fine at editor scale; incremental reduction is a
    /// later optimization.)
    fn commit(&mut self, new: Vec<OperationEnvelope>) -> Result<EditOutcome, EditorError> {
        let unit = new.len();
        let kept = self.applied.len();
        // Append the unit tentatively and materialize the whole prefix; on any failure
        // (a rejected op, a rolled-back transaction, a diagnostic-only layout) truncate
        // back so the failed edit leaves all state — the op log included — untouched.
        self.applied.extend(new.iter().cloned());
        match self.materialize(&self.applied) {
            Ok(materialized) => {
                let graph_changed = materialized.score != self.score;
                // Record the unit permanently (the monotonic id source) and as the new
                // active tail; a new edit forks history past any undos.
                self.authored.extend(new);
                self.undo_units.push(unit);
                self.redo_stack.clear();
                self.install(materialized);
                let selection_preserved = self.reresolve_selection();
                self.reresolve_caret();
                Ok(EditOutcome {
                    graph_changed,
                    selection_preserved,
                })
            }
            Err(err) => {
                self.applied.truncate(kept);
                Err(err)
            }
        }
    }

    /// Undoes the most recent edit (one user action — a primitive, or a whole
    /// transaction). Re-reduces the log without that unit, so even a delete is undone
    /// (its tombstone is simply never produced). The unit moves to the redo stack.
    /// `None` if there is nothing to undo.
    pub fn undo(&mut self) -> Option<EditOutcome> {
        let unit = *self.undo_units.last()?;
        let cut = self.applied.len() - unit;
        // A prefix of a valid log is itself valid (units are dropped from the end, so
        // every remaining op's causal predecessors are still present), so this succeeds.
        let materialized = self.materialize(&self.applied[..cut]).ok()?;
        let graph_changed = materialized.score != self.score;
        let undone = self.applied.split_off(cut);
        self.redo_stack.push(undone);
        self.undo_units.pop();
        self.install(materialized);
        let selection_preserved = self.reresolve_selection();
        self.reresolve_caret();
        Some(EditOutcome {
            graph_changed,
            selection_preserved,
        })
    }

    /// Redoes the most recently undone edit, re-appending its unit and re-reducing.
    /// `None` if there is nothing to redo (a new edit clears the redo stack).
    pub fn redo(&mut self) -> Option<EditOutcome> {
        let unit = self.redo_stack.last()?.clone();
        let kept = self.applied.len();
        self.applied.extend(unit.iter().cloned());
        let Ok(materialized) = self.materialize(&self.applied) else {
            self.applied.truncate(kept); // leave the redo stack intact on the (unexpected) failure
            return None;
        };
        let graph_changed = materialized.score != self.score;
        self.redo_stack.pop();
        self.undo_units.push(unit.len());
        self.install(materialized);
        let selection_preserved = self.reresolve_selection();
        self.reresolve_caret();
        Some(EditOutcome {
            graph_changed,
            selection_preserved,
        })
    }

    /// Whether there is an applied edit to [`undo`](Self::undo).
    pub fn can_undo(&self) -> bool {
        !self.undo_units.is_empty()
    }

    /// Whether there is an undone edit to [`redo`](Self::redo).
    pub fn can_redo(&self) -> bool {
        !self.redo_stack.is_empty()
    }

    /// Reduces `log` onto `base` and renders it — the materialization every edit, undo,
    /// and redo installs. Errors with [`EditorError::RejectedOperation`] if an op does
    /// not accept or the reduction is not clean (a transaction whose members fail rolls
    /// back as a conflict yet still returns a score), or [`EditorError::NotRenderable`]
    /// for a diagnostic-only layout.
    fn materialize(&self, log: &[OperationEnvelope]) -> Result<Materialization, EditorError> {
        let mut set = OperationSet::new();
        for env in log {
            if !matches!(set.accept(env.clone()), AcceptOutcome::Accepted) {
                return Err(EditorError::RejectedOperation);
            }
        }
        let materialized = set.reduce_onto(&self.base);
        if !materialized.state.is_clean() {
            return Err(EditorError::RejectedOperation);
        }
        let score = materialized.score;
        let (start_clefs, resolved, render, map) =
            render_score(&score, self.solver.as_ref()).ok_or(EditorError::NotRenderable)?;
        Ok(Materialization {
            score,
            start_clefs,
            resolved,
            render,
            map,
        })
    }

    /// Installs a materialization as the session's current score, layout, and render.
    fn install(&mut self, materialized: Materialization) {
        self.score = materialized.score;
        self.start_clefs = materialized.start_clefs;
        self.resolved = materialized.resolved;
        self.render = materialized.render;
        self.map = materialized.map;
    }

    /// Applies a single primitive operation: mints an envelope and commits it. A
    /// [`OperationKind::DeclareTransaction`] is not a primitive mutation — the session
    /// declares transactions via [`Self::apply_transaction`] — so it is refused here.
    ///
    /// Two pre-mint gates run, in order (both refuse with the op log untouched):
    ///
    /// 1. **Edit barriers** (Chapter 8 §"Behavior Under Unknown Extensions"):
    ///    the edit is checked against every active barrier; a match is refused
    ///    ([`EditorError::BarrierProhibited`]). The barrier gate runs *first*
    ///    because its prohibition is the spec's normative MUST and its refusal
    ///    names the unsafe-edit escape ([`Self::apply_unsafe`]); the advisory
    ///    check is the author's local policy.
    /// 2. **Advisory preconditions** (Chapter 6 §"Validation Modes"): the
    ///    session is authoring mode, so advisory preconditions are checked
    ///    against the current materialized score, and any violation refuses the
    ///    edit ([`EditorError::AdvisoryViolation`]). Reduction itself stays pure
    ///    replay mode — it never consults advisory checks.
    pub fn apply(&mut self, kind: OperationKind) -> Result<EditOutcome, EditorError> {
        if matches!(kind, OperationKind::DeclareTransaction(_)) {
            return Err(EditorError::DeclareTransactionNotAllowed);
        }
        if let Some(refusal) = self.barrier_refusal(&kind) {
            return Err(refusal);
        }
        self.apply_past_barriers(kind)
    }

    /// Applies a single primitive operation as an **unsafe edit** (Chapter 8
    /// §"Behavior Under Unknown Extensions"): the explicit user action that
    /// crosses matching edit barriers, acknowledging the loss of the crossed
    /// extensions' data. Everything else about [`Self::apply`] holds — the
    /// advisory gate still runs, and a failed edit changes nothing (including
    /// the tombstone record: an edit that did not land loses no data).
    ///
    /// On success, every crossed extension is recorded in
    /// [`Self::extensions_requiring_tombstone`] (the spec's MUST: its chunks
    /// are tombstoned rather than silently invalidated) and removed from the
    /// active set. An unsafe apply that crosses no barrier is an ordinary
    /// apply.
    pub fn apply_unsafe(&mut self, kind: OperationKind) -> Result<EditOutcome, EditorError> {
        if matches!(kind, OperationKind::DeclareTransaction(_)) {
            return Err(EditorError::DeclareTransactionNotAllowed);
        }
        let crossed = self.crossed_extensions(&kind);
        let outcome = self.apply_past_barriers(kind)?;
        for extension in crossed {
            self.record_unsafe_crossing(extension);
        }
        Ok(outcome)
    }

    /// The shared tail of [`Self::apply`] / [`Self::apply_unsafe`]: everything
    /// past the barrier gate (advisory gate, mint, commit).
    fn apply_past_barriers(&mut self, kind: OperationKind) -> Result<EditOutcome, EditorError> {
        let violations = advisory_violations(&kind, &self.score);
        if !violations.is_empty() {
            return Err(EditorError::AdvisoryViolation { violations });
        }
        // The next id is one past every id ever minted (monotonic across undo), and the
        // context covers the currently-applied ops.
        let counter = self.authored.len() as u64;
        let envelope = self.envelope_at(
            counter,
            OperationPayload::Primitive(kind),
            None,
            self.active_prior_context(),
        );
        self.commit(vec![envelope])
    }

    /// Applies a sequence of primitive operations as one **atomic transaction**: a
    /// `DeclareTransaction` descriptor (carrying `label` and `category`, for undo
    /// history) plus one member envelope per `kind`, all committed together. The
    /// reducer applies the members all-or-nothing — if any member's precondition
    /// fails, the whole block rolls back as a transaction conflict — and a peer
    /// replaying the log reconstructs the same atomic unit. This is the substrate for
    /// intents that must land several primitives together (e.g. a value change with a
    /// matching respelling); editor-level atomicity (commit only on full success) is
    /// inherited from the private `commit` path.
    pub fn apply_transaction(
        &mut self,
        label: &str,
        category: Option<TransactionCategory>,
        kinds: Vec<OperationKind>,
    ) -> Result<EditOutcome, EditorError> {
        Self::check_transaction_shape(&kinds)?;
        // The barrier gate (see `apply` for the gate ordering): the descriptor
        // the session will mint and every member are each checked against every
        // active barrier; any match refuses the whole transaction before
        // anything is minted.
        if let Some(refusal) = self.transaction_barrier_refusal(&kinds) {
            return Err(refusal);
        }
        self.apply_transaction_past_barriers(label, category, kinds)
    }

    /// [`Self::apply_transaction`] as an **unsafe edit** — the transaction
    /// sibling of [`Self::apply_unsafe`]: matching edit barriers are crossed
    /// rather than refused, and on success every crossed extension is recorded
    /// for tombstoning and deactivated. The structural checks and the advisory
    /// gate still apply, and a transaction that fails to commit records
    /// nothing.
    pub fn apply_transaction_unsafe(
        &mut self,
        label: &str,
        category: Option<TransactionCategory>,
        kinds: Vec<OperationKind>,
    ) -> Result<EditOutcome, EditorError> {
        Self::check_transaction_shape(&kinds)?;
        let mut crossed = self.crossed_extensions(&Self::descriptor_probe());
        for kind in &kinds {
            for extension in self.crossed_extensions(kind) {
                if !crossed.contains(&extension) {
                    crossed.push(extension);
                }
            }
        }
        let outcome = self.apply_transaction_past_barriers(label, category, kinds)?;
        for extension in crossed {
            self.record_unsafe_crossing(extension);
        }
        Ok(outcome)
    }

    /// The structural refusals shared by both transaction entry points.
    fn check_transaction_shape(kinds: &[OperationKind]) -> Result<(), EditorError> {
        // A member-less transaction would log a descriptor-only no-op (a dead
        // undo/sync unit). Refuse before minting anything.
        if kinds.is_empty() {
            return Err(EditorError::EmptyTransaction);
        }
        // The session declares the transaction; a member that is itself a
        // DeclareTransaction would log a nested no-op declaration. Refuse it.
        if kinds
            .iter()
            .any(|k| matches!(k, OperationKind::DeclareTransaction(_)))
        {
            return Err(EditorError::DeclareTransactionNotAllowed);
        }
        Ok(())
    }

    /// The first active-barrier match across the transaction the session is
    /// about to mint: the `DeclareTransaction` descriptor (a score-level
    /// operation a score-wide barrier can prohibit), then each member in order.
    fn transaction_barrier_refusal(&self, kinds: &[OperationKind]) -> Option<EditorError> {
        self.barrier_refusal(&Self::descriptor_probe())
            .or_else(|| kinds.iter().find_map(|kind| self.barrier_refusal(kind)))
    }

    /// A stand-in `DeclareTransaction` for gating: barrier matching reads only
    /// the operation *class* (and, for a descriptor, no payload-named object),
    /// so the placeholder id and label never influence the verdict.
    fn descriptor_probe() -> OperationKind {
        OperationKind::DeclareTransaction(TransactionDescriptor {
            id: TransactionId::new(ReplicaId(0), 0),
            label: String::new(),
            category: None,
        })
    }

    /// The shared tail of [`Self::apply_transaction`] /
    /// [`Self::apply_transaction_unsafe`]: everything past the barrier gate.
    fn apply_transaction_past_barriers(
        &mut self,
        label: &str,
        category: Option<TransactionCategory>,
        kinds: Vec<OperationKind>,
    ) -> Result<EditOutcome, EditorError> {
        // Authoring mode (see `apply`): every member must pass its advisory
        // preconditions before anything is minted. Members are checked against
        // the current materialized score — the pre-transaction state — which is
        // conservative for the rare member that only violates against another
        // member's intermediate effect (advisory checks are the author's local
        // policy, not canonical state, so this cannot diverge replicas).
        let violations: Vec<_> = kinds
            .iter()
            .flat_map(|kind| advisory_violations(kind, &self.score))
            .collect();
        if !violations.is_empty() {
            return Err(EditorError::AdvisoryViolation { violations });
        }
        let envelopes = self.transaction_envelopes(label, category, kinds);
        self.commit(envelopes)
    }

    /// Mints the envelopes for an atomic transaction: a `DeclareTransaction`
    /// descriptor at the next counter, then one member envelope per kind at the
    /// following counters, each referencing the transaction id. Each envelope's context
    /// covers the ones before it (the active prefix, then the descriptor, then earlier
    /// members), which gives every member descriptor-precedence over the descriptor for
    /// free. Pure.
    fn transaction_envelopes(
        &self,
        label: &str,
        category: Option<TransactionCategory>,
        kinds: Vec<OperationKind>,
    ) -> Vec<OperationEnvelope> {
        let base = self.authored.len() as u64;
        let tx_id = self.mint_transaction_id();
        let descriptor = TransactionDescriptor {
            id: tx_id,
            label: label.to_string(),
            category,
        };
        let mut envelopes = Vec::with_capacity(kinds.len() + 1);
        // Thread the context: each envelope sees the active prefix plus every earlier
        // envelope in this unit.
        let mut context = self.active_prior_context();
        let descriptor_env = self.envelope_at(
            base,
            OperationPayload::Primitive(OperationKind::DeclareTransaction(descriptor)),
            None,
            context.clone(),
        );
        context = extend_context(context, descriptor_env.id);
        envelopes.push(descriptor_env);
        for (i, kind) in kinds.into_iter().enumerate() {
            let counter = base + 1 + i as u64;
            let member = self.envelope_at(
                counter,
                OperationPayload::Primitive(kind),
                Some(tx_id),
                context.clone(),
            );
            context = extend_context(context, member.id);
            envelopes.push(member);
        }
        envelopes
    }

    /// Mints a fresh [`TransactionId`] in the session's replica namespace, one past the
    /// highest transaction counter declared in this session's **authored** history
    /// (so an undone transaction's id is never reused). Transaction ids live only in the
    /// op stream — the materialized score retains no trace — so the log is the sole
    /// source.
    fn mint_transaction_id(&self) -> TransactionId {
        let next = self
            .authored
            .iter()
            .filter_map(declared_transaction_id)
            .filter(|t| t.replica() == self.replica)
            .map(|t| t.counter())
            .max()
            .map_or(0, |c| {
                c.checked_add(1)
                    .expect("transaction id counter overflowed u64")
            });
        TransactionId::new(self.replica, next)
    }

    /// Transposes the **anchor**'s pitch by `interval`. Errors if nothing — or a
    /// non-pitch — is the anchor. The operation *refuses* (a clean `NoOp` effect,
    /// `graph_changed == false`) if the pitch cannot be faithfully transposed:
    /// a non-CMN position, a pitch pinned to an absolute frequency, or a result
    /// that overflows its `alteration` or `octave`.
    ///
    /// A single-target intent (retargeted to the anchor, `req:` unaffected by the
    /// selection set): a multi-member set's *other* members are not touched — batch
    /// transpose over the whole set is [`Self::alter_selection`].
    ///
    /// Authors `TransposeInterval`. The frozen `Transpose` is never emitted by
    /// new authoring (`req:opcat:transpose-frozen`).
    pub fn transpose_selection(
        &mut self,
        interval: TranspositionInterval,
    ) -> Result<EditOutcome, EditorError> {
        let selection = self
            .selection
            .anchor()
            .copied()
            .ok_or(EditorError::NoSelection)?;
        let TypedObjectId::Pitch(pitch) = selection.source else {
            return Err(EditorError::WrongSelection { expected: "pitch" });
        };
        self.apply(OperationKind::TransposeInterval(TransposeIntervalOp {
            targets: [pitch].into_iter().collect(),
            interval,
        }))
    }

    /// Sharpens (`+1`) or flattens (`-1`) **every selected pitch** by `semitones`: a
    /// chromatic alteration with no diatonic motion, so each notehead keeps its
    /// staff line and only the accidental changes — the "+1 semitone" key, batched
    /// over the whole selection set.
    ///
    /// It is *not* general transposition. `alter_selection(12)` asks for a C
    /// with twelve sharps; `transpose_selection` with `(7, 12)` asks for the C
    /// an octave up. A scalar cannot tell those apart — precisely the defect
    /// that froze the old operation (P12-K2).
    ///
    /// **Batch, atomic, and *not* wrapped in [`Self::apply_transaction`]**:
    /// `TransposeIntervalOp` already carries a *set* of targets and is atomic over
    /// it at the reducer (`req:opcat:transpose-interval-atomic` — every target's new
    /// value and spelling is resolved before any is written, and the whole operation
    /// refuses if any target cannot transpose), so a selection's pitches ride one
    /// primitive operation naming every one of them, exactly like
    /// [`Self::transpose_selection`] does for its single target. This is a
    /// deliberate departure from the delete-selection style (`DeclareTransaction` +
    /// one member per object): wrapping N single-target `TransposeInterval` ops in a
    /// transaction here would both add a `DeclareTransaction` envelope this method
    /// has never emitted (breaking the N=1 op-stream compatibility every other
    /// intent in this set model preserves) and replicate a strictly more verbose
    /// encoding than the wire format already provides for this exact case — see
    /// `DECISIONS.md`. Non-pitch members are **ignored**, not refused (an "alter"
    /// has nothing to say about a selected slur or rest, unlike a delete). Errors
    /// with [`EditorError::NoSelection`] if the set is empty, or
    /// [`EditorError::WrongSelection`] if it holds no pitch at all — both match
    /// today's single-selection errors exactly when the set has one member.
    pub fn alter_selection(&mut self, semitones: i32) -> Result<EditOutcome, EditorError> {
        if self.selection.members.is_empty() {
            return Err(EditorError::NoSelection);
        }
        let targets: BTreeSet<PitchId> = self
            .selection
            .members
            .iter()
            .filter_map(|selection| match selection.source {
                TypedObjectId::Pitch(pitch) => Some(pitch),
                _ => None,
            })
            .collect();
        if targets.is_empty() {
            return Err(EditorError::WrongSelection { expected: "pitch" });
        }
        self.apply(OperationKind::TransposeInterval(TransposeIntervalOp {
            targets,
            interval: TranspositionInterval {
                diatonic_steps: 0,
                chromatic_steps: semitones,
            },
        }))
    }

    /// Deletes **every selected object**. A selected **pitch** (a notehead) is
    /// tombstoned — the note's last pitch degrades its event to a rest of the same
    /// duration, so the rhythm survives; a selected **event** (a rest, a stem) is
    /// deleted whole. Errors — with nothing changed — if the set is empty, or any
    /// member is neither a pitch nor an event (its delete op cannot even be built).
    ///
    /// **A single-member set applies exactly like today** (one primitive op via
    /// [`Self::apply`], no transaction wrapper). **More than one member deletes as
    /// one [`Self::apply_transaction`]**: the reducer applies transaction members
    /// all-or-nothing, so a refused member (e.g. a tuplet member — the plain
    /// `NotInTuplet` compensation this method always uses can never be valid for
    /// one — refuses under `TupletCompensationInvalid`) rolls back the **whole
    /// batch**, leaving the score byte-identical to before the call. This mirrors
    /// [`Self::insert_note_at`] / [`Self::set_selection_duration`]'s existing
    /// "no transaction for one op" rule, applied to selection size instead of
    /// make-room size.
    ///
    /// The selection does not survive a delete (every deleted member's layout
    /// object is gone), so the set is cleared.
    pub fn delete_selection(&mut self) -> Result<EditOutcome, EditorError> {
        if self.selection.members.is_empty() {
            return Err(EditorError::NoSelection);
        }
        let kinds: Vec<OperationKind> = self
            .selection
            .members
            .iter()
            .map(|selection| match selection.source {
                TypedObjectId::Pitch(pitch) => Ok(OperationKind::DeleteIdentifiedPitch(
                    DeleteIdentifiedPitchOp { pitch },
                )),
                TypedObjectId::Event(event) => Ok(OperationKind::DeleteEvent(DeleteEventOp {
                    event,
                    tuplet_compensation: TupletCompensation::NotInTuplet,
                })),
                _ => Err(EditorError::WrongSelection {
                    expected: "pitch or event",
                }),
            })
            .collect::<Result<_, _>>()?;
        if kinds.len() == 1 {
            self.apply(kinds.into_iter().next().expect("checked len == 1"))
        } else {
            self.apply_transaction(
                "delete selection",
                Some(TransactionCategory::NoteEntry),
                kinds,
            )
        }
    }

    /// Moves the selected pitch by `steps` diatonic **staff steps** (`+1` is up one
    /// staff position, `-1` down): a nominal move that wraps the octave at the B↔C
    /// boundary and preserves the accidental — the "diatonic move" a vertical drag
    /// performs. It modifies the pitch in place ([`OperationKind::ModifyIdentifiedPitch`]),
    /// so the note keeps its identity and the selection survives the relayout.
    ///
    /// If the pitch carries an **authored spelling override** (which pins the rendered
    /// staff position), the move also rebases that spelling by the same step, applied
    /// **atomically** as one transaction — so the notehead and the sound move together
    /// rather than the move being refused.
    ///
    /// Errors if nothing — or a non-pitch — is selected, or the selected pitch (or its
    /// override) has no CMN staff position to step (an N-tone or grammar-defined
    /// position).
    ///
    /// A single-target intent: acts on the **anchor** only (byte-identical to the
    /// pre-selection-set behavior when the set has one member); the rest of a
    /// multi-member set is untouched.
    pub fn move_selection_staff_step(&mut self, steps: i32) -> Result<EditOutcome, EditorError> {
        let selection = self
            .selection
            .anchor()
            .copied()
            .ok_or(EditorError::NoSelection)?;
        let TypedObjectId::Pitch(pitch) = selection.source else {
            return Err(EditorError::WrongSelection { expected: "pitch" });
        };
        // The move is relative to the pitch's current value, so read it from the
        // graph and step its staff position.
        let current = self
            .current_pitch(pitch)
            .ok_or(EditorError::WrongSelection { expected: "pitch" })?;
        let moved = staff_step(&current, steps).ok_or(EditorError::WrongSelection {
            expected: "CMN pitch",
        })?;
        let modify = OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
            pitch,
            value: moved,
        });

        // With no override, the inferred spelling follows the new value — a plain
        // modify suffices. With one, step it too and land both atomically, so the
        // pinned notehead moves with the sound.
        match authored_spelling(&self.score, pitch) {
            None => self.apply(modify),
            Some(spelling) => {
                let moved_spelling =
                    staff_step_spelling(&spelling, steps).ok_or(EditorError::WrongSelection {
                        expected: "CMN pitch",
                    })?;
                self.apply_transaction(
                    "move note",
                    Some(TransactionCategory::NoteEntry),
                    vec![
                        modify,
                        OperationKind::RespellPitch(RespellPitchOp {
                            pitch,
                            spelling: moved_spelling,
                        }),
                    ],
                )
            }
        }
    }

    /// Adds a note to the selected pitch's event, forming (or extending) a chord: a
    /// new identified pitch one diatonic staff step above the event's **rendered**-
    /// highest note, with a fresh id ([`OperationKind::InsertIdentifiedPitch`]). The
    /// selection is unchanged — the anchored notehead is still there. Errors if
    /// nothing — or a non-pitch — is selected, or the event has no CMN note to step
    /// above.
    ///
    /// The "highest note" is ranked by each note's **resolved** spelling (the staff
    /// position the layout draws it at), so an authored respelling — or an inferred
    /// one — is honored rather than refused. The default is deliberately minimal (a
    /// real client picks the inserted pitch); stepping above the top note means
    /// repeated calls build a rising chord rather than stacking on one position.
    ///
    /// A single-target intent: acts on the **anchor** only.
    pub fn add_note_to_selection(&mut self) -> Result<EditOutcome, EditorError> {
        let selection = self
            .selection
            .anchor()
            .copied()
            .ok_or(EditorError::NoSelection)?;
        let TypedObjectId::Pitch(anchor) = selection.source else {
            return Err(EditorError::WrongSelection { expected: "pitch" });
        };
        let (event, _) = self
            .event_and_pitch_of(anchor)
            .ok_or(EditorError::WrongSelection { expected: "pitch" })?;
        // Rank the chord's notes by their *resolved* staff position — the spelling the
        // layout renders, which an authored override or an inferred respelling can move
        // off the raw pitch position — and step one staff step above the rendered-
        // highest note. (Resolved-spelling-aware, so an authored override no longer
        // refuses.) The new note's raw position is the rendered top's `+ 1`: stepping
        // the top's pitch by `rendered_top + 1 - raw_top` carries its alteration and
        // lands its raw position there; the acoustic realization resets so the new note
        // sounds at its written height, not the top's frequency.
        let (top, rendered_top) =
            self.rendered_top_of_event(event)
                .ok_or(EditorError::WrongSelection {
                    expected: "CMN pitch",
                })?;
        let raw_top = diatonic_index(&top).ok_or(EditorError::WrongSelection {
            expected: "CMN pitch",
        })?;
        let value = note_stepped(&top, (rendered_top + 1 - raw_top) as i32).ok_or(
            EditorError::WrongSelection {
                expected: "CMN pitch",
            },
        )?;
        let pitch = IdentifiedPitch {
            id: self.mint_pitch_id(),
            pitch: value,
        };
        self.apply(OperationKind::InsertIdentifiedPitch(
            InsertIdentifiedPitchOp { event, pitch },
        ))
    }

    /// The note in `event` that renders highest, and that rendered staff index — ranked
    /// by each note's **resolved** spelling (from [`derive_annotations`], the same
    /// spellings the layout places noteheads from), so an authored override or an
    /// inferred respelling is accounted for. Falls back to a note's raw pitch position
    /// when it has no resolved CMN spelling. `None` if the event has no CMN note.
    fn rendered_top_of_event(&self, event: EventId) -> Option<(Pitch, i64)> {
        let annotations = derive_annotations(&self.score, &PrePassProfile::default())
            .expect("the default pre-pass algorithms are supported");
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        self.score
            .events
            .get(event)?
            .collect_identified_pitches(&mut buf);
        buf.iter()
            .filter_map(|ip| {
                resolved_staff_index(&annotations, ip.id, &ip.pitch).map(|d| (d, &ip.pitch))
            })
            .max_by_key(|(d, _)| *d)
            .map(|(d, p)| (p.clone(), d))
    }

    /// Inserts a new note in the selected pitch's voice immediately after its event:
    /// a fresh single-note event ([`OperationKind::InsertEvent`]) at the next musical
    /// position, copying the selected pitch and its rhythmic value. The selection is
    /// unchanged — the anchored notehead is still there.
    ///
    /// If the selected pitch carries an **authored spelling override**, the copy carries
    /// it too: the insert lands **atomically** with a `RespellPitch` that gives the new
    /// note the same spelling, so the copy renders like the original.
    ///
    /// Errors if nothing — or a non-pitch — is selected, the anchor event is not a
    /// metric (musical) event in a metric region, or the position right after it is
    /// already occupied in the voice ([`EditorError::InsertSlotOccupied`]) — inserting
    /// into a packed voice needs an explicit make-room policy, a follow-up.
    ///
    /// A single-target intent: acts on the **anchor** only.
    pub fn insert_note_after_selection(&mut self) -> Result<EditOutcome, EditorError> {
        let selection = self
            .selection
            .anchor()
            .copied()
            .ok_or(EditorError::NoSelection)?;
        let TypedObjectId::Pitch(anchor) = selection.source else {
            return Err(EditorError::WrongSelection { expected: "pitch" });
        };
        let (event_id, anchor_value) = self
            .event_and_pitch_of(anchor)
            .ok_or(EditorError::WrongSelection { expected: "pitch" })?;

        // The anchor's voice, position, and duration — the position and duration must
        // be metric, with a positive duration (InsertEvent rejects a zero/negative
        // span and a non-metric region).
        let (voice, position, duration) = {
            let ev = self
                .score
                .events
                .get(event_id)
                .ok_or(EditorError::WrongSelection { expected: "pitch" })?;
            let EventPosition::Musical(position) = ev.position().clone() else {
                return Err(EditorError::WrongSelection {
                    expected: "metric event",
                });
            };
            let EventDuration::Musical(duration) = ev.duration().clone() else {
                return Err(EditorError::WrongSelection {
                    expected: "metric event",
                });
            };
            (ev.voice(), position, duration)
        };
        if !duration.is_positive() {
            return Err(EditorError::WrongSelection {
                expected: "metric event",
            });
        }
        let next_position = position + duration.clone();

        // Resolve the voice's region + staff instance together and require a metric
        // region — the reducer's InsertEvent precondition rejects any other time model.
        let staff_instance =
            self.metric_staff_instance_of_voice(voice)
                .ok_or(EditorError::WrongSelection {
                    expected: "metric event",
                })?;
        // Pre-check the slot so the edit refuses cleanly instead of being silently
        // no-op'd (and logged) by the reducer's voice-overlap rule.
        if self.voice_slot_occupied(voice, &next_position, &duration) {
            return Err(EditorError::InsertSlotOccupied);
        }

        let new_pitch = self.mint_pitch_id();
        let event = Event::Pitched(PitchedEvent {
            id: self.mint_event_id(),
            voice,
            position: EventPosition::Musical(next_position),
            duration: EventDuration::Musical(duration),
            pitches: vec![IdentifiedPitch {
                id: new_pitch,
                pitch: anchor_value,
            }],
            articulations: vec![],
            dynamic: None,
            ornaments: vec![],
            stem: StemConfiguration,
            grace: None,
        });
        let insert = OperationKind::InsertEvent(InsertEventOp {
            staff_instance,
            event,
        });

        // The new note copies the selected pitch's value; if that pitch has an authored
        // spelling, copy it onto the new note too — atomically, so the copy renders the
        // same. The respell targets the pitch the insert mints, so the insert must run
        // first; the transaction's canonical order (by counter) guarantees that.
        match authored_spelling(&self.score, anchor) {
            None => self.apply(insert),
            Some(spelling) => self.apply_transaction(
                "insert note",
                Some(TransactionCategory::NoteEntry),
                vec![
                    insert,
                    OperationKind::RespellPitch(RespellPitchOp {
                        pitch: new_pitch,
                        spelling,
                    }),
                ],
            ),
        }
    }

    /// Inserts a note at a world `point` on the beat grid — the **click-to-insert**
    /// ("pencil"): the pitch is the natural pitch at the cursor's height, the position
    /// is the grid-snapped onset, and the written duration is the grid `step`. The note
    /// goes into the staff's primary voice and **makes room** under an overwrite policy
    /// — an existing note/rest the new note fully covers is deleted, one it partially
    /// overlaps is trimmed, and one it lands inside is split (head trimmed, tail
    /// re-inserted), and a **tuplet** any part of the new note overlaps is cascaded away
    /// whole (every member tombstoned, structure removed — tuplets are atomic) — all as
    /// one transaction, so it applies atomically or not at all.
    ///
    /// Errors with [`EditorError::NoInsertTarget`] when the click resolves to no metric
    /// staff/position (or the overlap includes a non-note/rest event there is no
    /// make-room rule for), or [`EditorError::OverlapsTuplet`] when the overlapped member
    /// belongs to a *nested* tuplet, which the flat cascade cannot treat atomically.
    pub fn insert_note_at(
        &mut self,
        point: Point,
        grid: &GridResolution,
    ) -> Result<EditOutcome, EditorError> {
        let pitch = self
            .staff_pitch_at(point)
            .ok_or(EditorError::NoInsertTarget)?;
        let placed = self
            .position_at(point, grid)
            .ok_or(EditorError::NoInsertTarget)?;
        // The staff instance's region (must match the resolved position's) and its
        // primary voice — the new note's home.
        let (region, voice) = self
            .score
            .staff_instances()
            .find(|(_, si)| si.id == pitch.staff_instance)
            .and_then(|(region, si)| {
                let voice = si
                    .voices
                    .iter()
                    .find(|v| v.is_primary)
                    .or_else(|| si.voices.first())?;
                Some((region, voice.id))
            })
            .ok_or(EditorError::NoInsertTarget)?;
        if region != placed.region {
            return Err(EditorError::NoInsertTarget);
        }

        // The new note's half-open span; make room over whatever it overlaps.
        let start = placed.position;
        let duration = grid.step.clone();
        let end = start.clone() + duration.clone();
        let room = self.make_room(voice, &start, &end, None)?;

        let mut minter = self.minter();
        let mut ops = self.make_room_ops(room, pitch.staff_instance, &mut minter);
        let new_note = note_event(
            minter.event(),
            voice,
            start,
            duration,
            vec![IdentifiedPitch {
                id: minter.pitch(),
                pitch: cmn_pitch(pitch.nominal, pitch.octave),
            }],
        );
        ops.push(OperationKind::InsertEvent(InsertEventOp {
            staff_instance: pitch.staff_instance,
            event: new_note,
        }));

        // A bare insert needs no transaction; a make-room insert is atomic.
        if ops.len() == 1 {
            self.apply(ops.into_iter().next().expect("one op"))
        } else {
            self.apply_transaction("insert note", Some(TransactionCategory::NoteEntry), ops)
        }
    }

    /// The world `x` a musical `position` in `region` renders at — the forward
    /// companion of [`Self::position_at`], inverting the same piecewise-linear
    /// `(onset, x)` anchors (its private `position_anchors`) it does, extrapolating
    /// past the first/last anchor exactly as [`Self::position_at`]'s own inverse
    /// (the private `invert_x`) does. `within` scopes the anchors to one cast-off system,
    /// exactly mirroring `position_anchors`'s own parameter (`None` reads
    /// the whole region as one flat monotonic run — the common case without cast
    /// geometry, e.g. the stub solver); a caller drawing into a specific system
    /// supplies its box the same way [`Self::position_at`] derives one from a
    /// click point via its private `containing_system`. `None` when `region` is not
    /// metric, or fewer than two of its events render inside `within` to fix a
    /// scale — the same two-anchor floor [`Self::position_at`] enforces. `pub`
    /// so a GUI (W2) can draw the caret at its exact musical position.
    pub fn x_at_position(
        &self,
        region: RegionId,
        within: Option<&Rect>,
        position: &MusicalPosition,
    ) -> Option<f32> {
        if !self.region_is_metric(region) {
            return None;
        }
        let anchors = self.position_anchors(region, within);
        if anchors.len() < 2 {
            return None;
        }
        Some(forward_x(&anchors, position))
    }

    /// Enters a **natural** `nominal` (no accidental — alteration is a follow-up
    /// gesture via the existing transpose intents, MuseScore's model) at the
    /// caret. The octave is inferred as the one nearest, in diatonic staff steps,
    /// to the caret voice's reference pitch — the nearest preceding note strictly
    /// before the caret's position (its private `reference_pitch_before`), or octave 4
    /// with no reference (see the private `infer_octave` for the rule and its downward
    /// tie-break). Funnels to this session's shared insertion core.
    pub fn enter_nominal(&mut self, nominal: CmnNominal) -> Result<EditOutcome, EditorError> {
        let caret = self.caret.clone().ok_or(EditorError::NoCaret)?;
        let reference = self.reference_pitch_before(caret.voice, &caret.position);
        let octave = infer_octave(reference, nominal);
        self.enter_at_caret(Some(cmn_pitch(nominal, octave)))
    }

    /// Enters an explicit `pitch` at the caret — no octave inference; the caller
    /// names the exact pitch (e.g. via [`midi_note_to_pitch`], the step-time MIDI
    /// path `spec/PLAN_EDITOR_APP.md`'s T3 entry names as the primary input
    /// method). Funnels to this session's shared insertion core.
    pub fn enter_pitch(&mut self, pitch: Pitch) -> Result<EditOutcome, EditorError> {
        self.enter_at_caret(Some(pitch))
    }

    /// Enters a rest at the caret. Funnels to this session's shared insertion core.
    pub fn enter_rest(&mut self) -> Result<EditOutcome, EditorError> {
        self.enter_at_caret(None)
    }

    /// The shared insertion core [`Self::enter_nominal`]/[`Self::enter_pitch`]/
    /// [`Self::enter_rest`] funnel to: a fresh single-note (`pitch = Some`) or
    /// rest (`pitch = None`) event at the caret's `(voice, position)`, its
    /// written duration the caret's `entry_duration`, **make-room overwrite
    /// exactly as [`Self::insert_note_at`]** — the same `make_room`/
    /// `make_room_ops`/[`Minter`] machinery, one atomic apply/transaction (a bare
    /// insert needs no transaction; a make-room insert commits as one) — then
    /// **advances the caret by the entry duration, only on success**: a refused
    /// edit changes nothing, including where the caret sits.
    ///
    /// Errors with [`EditorError::NoCaret`] if no caret is set, or the pencil's
    /// own make-room refusals (see [`Self::insert_note_at`]).
    fn enter_at_caret(&mut self, pitch: Option<Pitch>) -> Result<EditOutcome, EditorError> {
        let caret = self.caret.clone().ok_or(EditorError::NoCaret)?;
        let staff_instance = self
            .metric_staff_instance_of_voice(caret.voice)
            .ok_or(EditorError::NoInsertTarget)?;
        let start = caret.position.clone();
        let duration = caret.entry_duration.clone();
        let end = start.clone() + duration.clone();
        let room = self.make_room(caret.voice, &start, &end, None)?;

        let mut minter = self.minter();
        let mut ops = self.make_room_ops(room, staff_instance, &mut minter);
        let pitches = match pitch {
            Some(p) => vec![IdentifiedPitch {
                id: minter.pitch(),
                pitch: p,
            }],
            None => Vec::new(),
        };
        let new_event = note_event(
            minter.event(),
            caret.voice,
            start,
            duration.clone(),
            pitches,
        );
        ops.push(OperationKind::InsertEvent(InsertEventOp {
            staff_instance,
            event: new_event,
        }));

        // A bare insert needs no transaction; a make-room insert is atomic.
        let outcome = if ops.len() == 1 {
            self.apply(ops.into_iter().next().expect("one op"))
        } else {
            self.apply_transaction("enter note", Some(TransactionCategory::NoteEntry), ops)
        }?;

        if let Some(c) = self.caret.as_mut() {
            c.position = c.position.clone() + duration;
        }
        Ok(outcome)
    }

    /// Copies the current selection to the clipboard fragment projection
    /// (Ruling E, `spec/PLAN_EDITOR_APP.md` §Ruling E): per-event **values**
    /// (pitches, durations, an authored spelling override if the source
    /// pitch carries one, relative onsets), in per-voice lanes keyed by
    /// ordinal — never source ids. Paste mints fresh ones.
    ///
    /// **Closure v1** (fail closed, report dropped): a note/rest copies with
    /// its per-event attachments; a slur or tie copies iff **both**
    /// endpoints are in the selection, else it is omitted from the fragment
    /// and named in [`CopyOutcome::dropped`]; a selection covering some but
    /// not all of a live tuplet's members refuses the whole copy
    /// ([`EditorError::PartialTupletSelection`]) — a tuplet is atomic, so a
    /// fragment cannot represent part of one.
    ///
    /// Errors with [`EditorError::NoSelection`] if nothing is selected, or
    /// [`EditorError::WrongSelection`] if a member is not a pitch/event, or
    /// resolves to an event this session cannot yet copy — a non-metric
    /// event, or a kind other than a note/rest (mirrors `Self::make_room`'s
    /// own note/rest-only, metric-only scope; Ruling E names no event-kind
    /// scope explicitly).
    pub fn copy_selection(&self) -> Result<CopyOutcome, EditorError> {
        if self.selection.members.is_empty() {
            return Err(EditorError::NoSelection);
        }
        // A rubber-band `select_within` routinely also catches incidental
        // geometry alongside the intended notes — staff lines, a barline, a
        // directly-selected slur or tuplet-as-object — since those render
        // real hit regions too (proven directly by this packet's own
        // geometry-driven tests). Ruling E names no policy for a mixed
        // selection; copy's own resolution, mirroring `alter_selection`'s
        // established "silently ignored" precedent (`DECISIONS.md`) rather
        // than `delete_selection`'s hard refusal: a member that is not a
        // pitch/event (or a pitch/event that does not resolve to a live,
        // metric note or rest) is skipped, not refused — copy is "copy the
        // notes in this range", not "refuse unless the range is surgically
        // exact". The whole copy still refuses if *nothing* usable remains.
        let mut event_ids: BTreeSet<EventId> = BTreeSet::new();
        for member in &self.selection.members {
            let event_id = match member.source {
                TypedObjectId::Pitch(pitch) => {
                    self.event_and_pitch_of(pitch).map(|(event, _)| event)
                }
                TypedObjectId::Event(event) => Some(event),
                _ => None,
            };
            if let Some(event_id) = event_id {
                event_ids.insert(event_id);
            }
        }
        if event_ids.is_empty() {
            return Err(EditorError::WrongSelection {
                expected: "a pitch or event",
            });
        }

        // Closure v1: a partially-selected tuplet refuses the whole copy —
        // the reducer's own atomicity discipline, at clipboard scale.
        let mut covered_tuplets: BTreeSet<TupletId> = BTreeSet::new();
        for &event_id in &event_ids {
            covered_tuplets.extend(self.tuplets_containing(event_id));
        }
        for tuplet in covered_tuplets {
            let members = self.tuplet_members(tuplet);
            if !members.iter().all(|m| event_ids.contains(m)) {
                return Err(EditorError::PartialTupletSelection { tuplet });
            }
        }

        // Only a metric note/rest is copyable in v1 (mirrors `make_room`'s
        // own scope; Ruling E names no event-kind scope explicitly). This
        // pass records every usable member's voice and onset (skipping, per
        // the policy above, anything that resolves but is not a live metric
        // note/rest), so the origin (the earliest onset) can be found before
        // any fragment event is built.
        let mut located: Vec<(EventId, VoiceId, MusicalPosition)> =
            Vec::with_capacity(event_ids.len());
        for &event_id in &event_ids {
            let Some(event) = self.score.events.get(event_id) else {
                continue;
            };
            if !matches!(event, Event::Pitched(_) | Event::Rest(_)) {
                continue;
            }
            let EventPosition::Musical(at) = event.position().clone() else {
                continue;
            };
            located.push((event_id, event.voice(), at));
        }
        if located.is_empty() {
            return Err(EditorError::WrongSelection {
                expected: "a metric note or rest",
            });
        }
        let origin = located
            .iter()
            .map(|(_, _, at)| at.clone())
            .min()
            .expect("located is non-empty, checked above");

        let mut by_voice: BTreeMap<VoiceId, Vec<(EventId, MusicalPosition)>> = BTreeMap::new();
        for (event_id, voice, at) in located {
            by_voice.entry(voice).or_default().push((event_id, at));
        }

        // Fragment-local refs, keyed by source EventId — built once so the
        // slur/tie pass below can resolve both endpoints without knowing
        // anything about voices or ordering itself.
        let mut refs: BTreeMap<EventId, fragment::EventRef> = BTreeMap::new();
        let mut voices = Vec::with_capacity(by_voice.len());
        for (voice_ordinal, (_voice, mut events)) in by_voice.into_iter().enumerate() {
            events.sort_by(|a, b| a.1.cmp(&b.1));
            for (event_ordinal, (event_id, _)) in events.iter().enumerate() {
                refs.insert(
                    *event_id,
                    fragment::EventRef {
                        voice: voice_ordinal as u32,
                        event: event_ordinal as u32,
                    },
                );
            }
            let fragment_events = events
                .iter()
                .map(|(event_id, _)| self.fragment_event(*event_id, &origin))
                .collect();
            voices.push(fragment::FragmentVoice {
                events: fragment_events,
            });
        }

        // Closure v1: both endpoints in the selection carries the slur/tie
        // into the fragment; exactly one is a boundary cut — dropped and
        // reported; neither is irrelevant to this copy.
        let mut slurs = Vec::new();
        let mut ties = Vec::new();
        let mut dropped = Vec::new();
        for slur in &self.score.cross_cutting.slurs {
            match (refs.get(&slur.start_event), refs.get(&slur.end_event)) {
                (Some(&start), Some(&end)) => slurs.push(fragment::FragmentSlur {
                    start,
                    end,
                    kind: slur.kind,
                    curvature_override: slur.curvature_override.clone(),
                    style: slur.style.clone(),
                }),
                (Some(_), None) | (None, Some(_)) => dropped.push(DroppedItem::Slur(slur.id)),
                (None, None) => {}
            }
        }
        for tie in &self.score.cross_cutting.ties {
            match (refs.get(&tie.start_event), refs.get(&tie.end_event)) {
                (Some(&start), Some(&end)) => ties.push(fragment::FragmentTie {
                    start,
                    end,
                    class: tie.class.clone(),
                    style: tie.style.clone(),
                }),
                (Some(_), None) | (None, Some(_)) => dropped.push(DroppedItem::Tie(tie.id)),
                (None, None) => {}
            }
        }

        let document = fragment::FragmentDocument {
            voices,
            slurs,
            ties,
        };
        let events_copied = document.voices.iter().map(|v| v.events.len()).sum();
        Ok(CopyOutcome {
            fragment: fragment::encode(&document),
            dropped,
            events_copied,
        })
    }

    /// The value-only [`fragment::FragmentEvent`] for the live `event_id`,
    /// its onset expressed relative to `origin` — [`Self::copy_selection`]'s
    /// per-event projection. `event_id` must already be known Pitched/Rest
    /// and metric (checked by the caller).
    fn fragment_event(
        &self,
        event_id: EventId,
        origin: &MusicalPosition,
    ) -> fragment::FragmentEvent {
        let event = self
            .score
            .events
            .get(event_id)
            .expect("event_id came from the current score (copy_selection)");
        let EventPosition::Musical(at) = event.position().clone() else {
            unreachable!("checked Musical in copy_selection")
        };
        let EventDuration::Musical(duration) = event.duration().clone() else {
            unreachable!("checked Musical in copy_selection")
        };
        let onset = span_between(origin, &at);
        let content = match event {
            Event::Rest(rest) => fragment::FragmentEventContent::Rest {
                vertical_position: rest.vertical_position,
                visible: rest.visible,
            },
            Event::Pitched(pe) => fragment::FragmentEventContent::Pitched {
                pitches: pe
                    .pitches
                    .iter()
                    .map(|ip| fragment::FragmentPitch {
                        pitch: ip.pitch.clone(),
                        spelling_override: authored_spelling(&self.score, ip.id),
                    })
                    .collect(),
                articulations: pe.articulations.clone(),
                dynamic: pe.dynamic.clone(),
                ornaments: pe.ornaments.clone(),
                stem: pe.stem.clone(),
                grace: pe.grace.clone(),
            },
            _ => unreachable!("checked Pitched|Rest in copy_selection"),
        };
        fragment::FragmentEvent {
            onset,
            duration,
            content,
        }
    }

    /// Pastes `fragment` at a world `point` on `grid` — pencil-style, via
    /// [`Self::position_at`] (the target musical position) and
    /// [`Self::staff_pitch_at`] (the target staff instance; only its
    /// `staff_instance` is used — the fragment's own pitches replace
    /// whatever height was clicked). Ruling E: "`paste_at(point, &grid)`
    /// (pencil-style via `position_at`) … make-room overwrite …, atomic
    /// transaction".
    ///
    /// The fragment's voice **lanes** map positionally onto the clicked
    /// staff instance's voices (lane 0 → its first voice, and so on); see
    /// [`EditorError::InsufficientVoicesForFragment`] for what happens when
    /// the destination has fewer.
    pub fn paste_at(
        &mut self,
        point: Point,
        grid: &GridResolution,
        fragment: &str,
    ) -> Result<PasteOutcome, EditorError> {
        let pitch = self
            .staff_pitch_at(point)
            .ok_or(EditorError::NoInsertTarget)?;
        let placed = self
            .position_at(point, grid)
            .ok_or(EditorError::NoInsertTarget)?;
        let (region, si) = self
            .score
            .staff_instances()
            .find(|(_, si)| si.id == pitch.staff_instance)
            .ok_or(EditorError::NoInsertTarget)?;
        if region != placed.region {
            return Err(EditorError::NoInsertTarget);
        }
        let voice_ids: Vec<VoiceId> = si.voices.iter().map(|v| v.id).collect();
        self.paste_document(fragment, pitch.staff_instance, &voice_ids, placed.position)
    }

    /// Pastes `fragment` at the **anchor** member's own onset, in its own
    /// voice (Ruling E). The lane→voice mapping is [`Self::paste_at`]'s same
    /// positional rule, rooted at the anchor's staff instance.
    ///
    /// Errors with [`EditorError::NoSelection`] if nothing is selected, or
    /// [`EditorError::WrongSelection`] if the anchor is not a pitch/event
    /// resolving to a live metric note or rest.
    pub fn paste_over_selection(&mut self, fragment: &str) -> Result<PasteOutcome, EditorError> {
        let selection = self
            .selection
            .anchor()
            .copied()
            .ok_or(EditorError::NoSelection)?;
        let event_id = match selection.source {
            TypedObjectId::Pitch(pitch) => self.event_and_pitch_of(pitch).map(|(e, _)| e),
            TypedObjectId::Event(event) => Some(event),
            _ => None,
        }
        .ok_or(EditorError::WrongSelection {
            expected: "pitch or event",
        })?;
        let (voice, at) = {
            let event = self
                .score
                .events
                .get(event_id)
                .ok_or(EditorError::WrongSelection {
                    expected: "pitch or event",
                })?;
            let EventPosition::Musical(at) = event.position().clone() else {
                return Err(EditorError::WrongSelection {
                    expected: "metric event",
                });
            };
            (event.voice(), at)
        };
        let staff_instance =
            self.metric_staff_instance_of_voice(voice)
                .ok_or(EditorError::WrongSelection {
                    expected: "metric event",
                })?;
        let si = self
            .score
            .staff_instances()
            .find(|(_, si)| si.id == staff_instance)
            .map(|(_, si)| si)
            .expect("metric_staff_instance_of_voice resolved a real staff instance");
        let voice_ids: Vec<VoiceId> = si.voices.iter().map(|v| v.id).collect();
        self.paste_document(fragment, staff_instance, &voice_ids, at)
    }

    /// The shared tail of [`Self::paste_at`] / [`Self::paste_over_selection`]:
    /// decodes `fragment`, maps each voice lane onto `voice_ids` positionally
    /// (lane `i` → `voice_ids[i]`), clears each lane's own span under
    /// [`Self::make_room`]'s overwrite policy, and mints `InsertEvent` (+
    /// `RespellPitch` for a carried authored spelling) for every fragment
    /// event, then re-mints every fragment slur/tie whose endpoints resolve
    /// to freshly-minted destination events as a fresh `CreateCrossCutting`
    /// (closing the W4a follow-up, `DECISIONS.md` 2026-07-24) — all as
    /// **one** atomic transaction (Ruling E), so a refusal anywhere (e.g. a
    /// lane's make-room hits a nested tuplet) rolls back the whole paste,
    /// cross-cuttings included.
    ///
    /// **Id mapping.** A fragment's [`fragment::EventRef`] names an event by
    /// its position in the fragment's own per-voice lanes, never a source
    /// id (Ruling E). `event_map[voice_ordinal][event_ordinal]` records the
    /// fresh [`EventId`] minted for that position as the insert loop below
    /// mints it, so the slur/tie pass afterward resolves every endpoint by a
    /// plain index — never a source id, and never the source's own
    /// `SlurId`/`TieId`, which this session never even decodes (the
    /// fragment format carries none). Every reference [`fragment::decode`]
    /// accepted is guaranteed to resolve here (it already rejected any
    /// dangling one), so indexing is a direct array access, not a fallible
    /// lookup.
    ///
    /// **Tie pairing.** A replayed tie's `pitch_pairing` is always `None` —
    /// the fragment format never carries the source's explicit pairing
    /// (Ruling E: no object ids; `fragment::FragmentTie` has no
    /// `pitch_pairing` field at all). This is not a structural block: the
    /// reducer accepts `pitch_pairing: None` unconditionally (it means
    /// "pair enharmonically by ascending `PitchId`", Chapter 5 §"Ties") and
    /// enforces nothing about it at `CreateCrossCutting` apply time — the
    /// pairing-consistency check lives in `epiphany_core::invariants`, an
    /// optional graph-wide check this session never runs as part of
    /// `apply`/`apply_transaction`. See `DECISIONS.md` for the full
    /// investigation.
    fn paste_document(
        &mut self,
        fragment: &str,
        staff_instance: StaffInstanceId,
        voice_ids: &[VoiceId],
        target_start: MusicalPosition,
    ) -> Result<PasteOutcome, EditorError> {
        let document = fragment::decode(fragment)?;
        if document.voices.len() > voice_ids.len() {
            return Err(EditorError::InsufficientVoicesForFragment {
                needed: document.voices.len(),
                available: voice_ids.len(),
            });
        }

        let mut minter = self.minter();
        let mut ops: Vec<OperationKind> = Vec::new();
        let mut events_inserted = 0usize;
        // event_map[voice_ordinal][event_ordinal] -> the fresh EventId minted
        // for that fragment-local position. Pushed once per lane (an empty
        // Vec for a skipped/empty lane) so indices stay aligned with the
        // fragment's own voice ordinals for the slur/tie pass below.
        let mut event_map: Vec<Vec<EventId>> = Vec::with_capacity(document.voices.len());
        for (&voice, lane) in voice_ids.iter().zip(&document.voices) {
            if lane.events.is_empty() {
                event_map.push(Vec::new());
                continue;
            }
            let lane_end = lane
                .events
                .iter()
                .map(|e| target_start.clone() + e.onset.clone() + e.duration.clone())
                .max()
                .expect("lane.events is non-empty, checked above");
            let room = self.make_room(voice, &target_start, &lane_end, None)?;
            ops.extend(self.make_room_ops(room, staff_instance, &mut minter));
            let mut lane_map = Vec::with_capacity(lane.events.len());
            for fe in &lane.events {
                let position = target_start.clone() + fe.onset.clone();
                let event_id = minter.event();
                lane_map.push(event_id);
                let (event, respells) =
                    fragment_event_to_op_event(event_id, voice, position, fe, &mut minter);
                ops.push(OperationKind::InsertEvent(InsertEventOp {
                    staff_instance,
                    event,
                }));
                events_inserted += 1;
                ops.extend(respells);
            }
            event_map.push(lane_map);
        }

        // Every InsertEvent above precedes every CreateCrossCutting below in
        // the op list — required, not incidental: the reducer's own
        // `create_cross_cutting` precondition demands both endpoint events
        // already be Live in its per-envelope object registry
        // (`reduce.rs`'s `anchor_object_refs` check), so a slur/tie naming a
        // freshly-minted event only reduces cleanly when its insert has
        // already been processed earlier in the same transaction.
        let resolve =
            |r: fragment::EventRef| -> EventId { event_map[r.voice as usize][r.event as usize] };

        let mut slurs_inserted = 0usize;
        for slur in &document.slurs {
            let slur_id = minter.slur();
            ops.push(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
                structure: CrossCuttingValue::Slur(Slur {
                    id: slur_id,
                    start_event: resolve(slur.start),
                    end_event: resolve(slur.end),
                    kind: slur.kind,
                    curvature_override: slur.curvature_override.clone(),
                    style: slur.style.clone(),
                }),
            }));
            slurs_inserted += 1;
        }
        let mut ties_inserted = 0usize;
        for tie in &document.ties {
            let tie_id = minter.tie();
            ops.push(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
                structure: CrossCuttingValue::Tie(Tie {
                    id: tie_id,
                    start_event: resolve(tie.start),
                    end_event: resolve(tie.end),
                    // The fragment never carries the source's explicit
                    // pairing (see this method's doc); the reducer's
                    // implicit enharmonic pairing applies.
                    pitch_pairing: None,
                    class: tie.class.clone(),
                    style: tie.style.clone(),
                }),
            }));
            ties_inserted += 1;
        }

        if ops.is_empty() {
            return Err(EditorError::EmptyFragment);
        }

        let outcome = if ops.len() == 1 {
            self.apply(ops.into_iter().next().expect("len == 1"))?
        } else {
            self.apply_transaction("paste", Some(TransactionCategory::NoteEntry), ops)?
        };
        Ok(PasteOutcome {
            outcome,
            events_inserted,
            slurs_inserted,
            ties_inserted,
        })
    }

    /// Sets the selected event's written **duration** (a notation duration-palette
    /// gesture; the selection may be a notehead or a rest/stem). Shrinking just frees
    /// the space after the event; **lengthening makes room** under the overwrite policy
    /// — the events it grows over are trimmed, deleted, or split, and a tuplet it grows
    /// over is cascaded away whole (tuplets are atomic), atomically with the resize.
    /// Errors: [`InvalidDuration`](EditorError::InvalidDuration) for a non-positive
    /// duration, [`OverlapsTuplet`](EditorError::OverlapsTuplet) when the *selected* event
    /// is itself a tuplet member (in-place rescaling of a member is a later refinement) or
    /// it grows over a *nested* tuplet the flat cascade cannot treat atomically, and
    /// [`WrongSelection`](EditorError::WrongSelection) when nothing apt is selected or
    /// the event is not metric.
    ///
    /// A single-target intent: acts on the **anchor** only.
    pub fn set_selection_duration(
        &mut self,
        duration: MusicalDuration,
    ) -> Result<EditOutcome, EditorError> {
        if !duration.is_positive() {
            return Err(EditorError::InvalidDuration);
        }
        let selection = self
            .selection
            .anchor()
            .copied()
            .ok_or(EditorError::NoSelection)?;
        let event_id = match selection.source {
            TypedObjectId::Pitch(pitch) => self
                .event_and_pitch_of(pitch)
                .map(|(event, _)| event)
                .ok_or(EditorError::WrongSelection {
                expected: "pitch or event",
            })?,
            TypedObjectId::Event(event) => event,
            _ => {
                return Err(EditorError::WrongSelection {
                    expected: "pitch or event",
                })
            }
        };
        // The event's voice and metric onset. Only a note or rest is resizable here (a
        // non-metric event has no note value; only `Pitched`/`Rest` carry a make-room
        // rule). A tuplet member's duration is ratio-governed, and a decomposed event's
        // notated components would no longer sum to a changed duration, so refuse both.
        let (voice, position) = {
            let event = self
                .score
                .events
                .get(event_id)
                .ok_or(EditorError::WrongSelection {
                    expected: "pitch or event",
                })?;
            if !matches!(event, Event::Pitched(_) | Event::Rest(_)) {
                return Err(EditorError::WrongSelection {
                    expected: "note or rest",
                });
            }
            let (EventPosition::Musical(position), EventDuration::Musical(_)) =
                (event.position().clone(), event.duration())
            else {
                return Err(EditorError::WrongSelection {
                    expected: "metric event",
                });
            };
            (event.voice(), position)
        };
        if self.event_in_tuplet(event_id) {
            return Err(EditorError::OverlapsTuplet);
        }
        if self.event_has_decomposition(event_id) {
            return Err(EditorError::DecomposedEvent);
        }
        // Require a metric region (its time model) — and resolve the staff instance for
        // any re-inserted split tails.
        let staff_instance =
            self.metric_staff_instance_of_voice(voice)
                .ok_or(EditorError::WrongSelection {
                    expected: "metric event",
                })?;

        // Lengthening claims [position, position + duration); make room over the other
        // events there (the resized event itself is excluded). Shrinking frees space, so
        // make-room finds nothing. The resize applies last, after the span is cleared.
        let end = position.clone() + duration.clone();
        let resized = replace_span(
            self.score.events.get(event_id).unwrap(),
            position.clone(),
            duration,
        );
        let room = self.make_room(voice, &position, &end, Some(event_id))?;

        let mut minter = self.minter();
        let mut ops = self.make_room_ops(room, staff_instance, &mut minter);
        ops.push(OperationKind::ModifyEvent(ModifyEventOp { event: resized }));
        if ops.len() == 1 {
            self.apply(ops.into_iter().next().expect("one op"))
        } else {
            self.apply_transaction("set duration", Some(TransactionCategory::NoteEntry), ops)
        }
    }

    /// The events make-room must change to clear `[start, end)` in `voice` (other than
    /// `exclude`, the event being inserted/resized): whole-event deletes, in-place
    /// trims, splits, and whole-tuplet cascade deletes. A tuplet is **atomic** — an
    /// overlap with any of its members removes the whole tuplet (every member, plus the
    /// structure), since a member's duration is ratio-bound and cannot be trimmed in
    /// place. Errors with [`OverlapsTuplet`](EditorError::OverlapsTuplet) for a *nested*
    /// tuplet (cascading one level only is not yet safe), [`NoInsertTarget`] for a
    /// non-note/rest overlap, and [`DecomposedEvent`](EditorError::DecomposedEvent) for a
    /// trim/split of a (non-tuplet) decomposed event.
    fn make_room(
        &self,
        voice: VoiceId,
        start: &MusicalPosition,
        end: &MusicalPosition,
        exclude: Option<EventId>,
    ) -> Result<MakeRoom, EditorError> {
        let mut room = MakeRoom::default();
        let mut cascade_tuplets: std::collections::BTreeSet<TupletId> =
            std::collections::BTreeSet::new();
        for event in self.score.events.iter() {
            if event.voice() != voice || Some(event.id()) == exclude {
                continue;
            }
            let (EventPosition::Musical(ep), EventDuration::Musical(ed)) =
                (event.position(), event.duration())
            else {
                continue;
            };
            // A non-positive existing span never occupies the range (matching the
            // reducer's overlap rule), so it is not in the way.
            if !ed.is_positive() {
                continue;
            }
            let event_end = ep.clone() + ed.clone();
            if !(ep < end && start < &event_end) {
                continue; // disjoint from [start, end)
            }
            // A tuplet member: mark its (flat) tuplet for whole-tuplet cascade deletion,
            // and stop treating it as an ordinary overlap. A nested tuplet is refused.
            let containing = self.tuplets_containing(event.id());
            if !containing.is_empty() {
                for tuplet in containing {
                    if !self.is_flat_tuplet(tuplet) {
                        return Err(EditorError::OverlapsTuplet);
                    }
                    cascade_tuplets.insert(tuplet);
                }
                continue;
            }
            if !matches!(event, Event::Pitched(_) | Event::Rest(_)) {
                return Err(EditorError::NoInsertTarget); // no make-room rule for this kind
            }
            // A trim or split changes the event's duration; refuse if a persistent
            // decomposition would no longer sum to it (a full-cover delete is fine — the
            // tombstoned target's decomposition is no longer checked).
            let fully_covered = ep >= start && &event_end <= end;
            if !fully_covered && self.event_has_decomposition(event.id()) {
                return Err(EditorError::DecomposedEvent);
            }
            match (ep < start, &event_end > end) {
                // Fully covered → delete.
                (false, false) => room.deletes.push(event.id()),
                // The span lands inside it → split: trim to the head, re-insert the tail.
                (true, true) => {
                    room.trims
                        .push(replace_span(event, ep.clone(), span_between(ep, start)));
                    room.tails
                        .push((event.clone(), end.clone(), span_between(end, &event_end)));
                }
                // Overlaps the head (event ends inside the span) → trim the tail off.
                (true, false) => {
                    room.trims
                        .push(replace_span(event, ep.clone(), span_between(ep, start)))
                }
                // Overlaps the tail (event starts inside the span) → trim the head off.
                (false, true) => room.trims.push(replace_span(
                    event,
                    end.clone(),
                    span_between(end, &event_end),
                )),
            }
        }
        // Expand each cascade tuplet into deletes of all its live members. The first
        // member removes the tuplet structure (so the rest no longer belong to a tuplet
        // and delete as ordinary events); the order is preserved by `make_room_ops`.
        for tuplet in cascade_tuplets {
            for (i, member) in self.tuplet_members(tuplet).into_iter().enumerate() {
                let compensation = if i == 0 {
                    TupletCompensation::CascadeDeleteTuplets {
                        tuplets: vec![tuplet],
                    }
                } else {
                    TupletCompensation::NotInTuplet
                };
                room.cascade_deletes.push((member, compensation));
            }
        }
        Ok(room)
    }

    /// The ids of the tuplets `event` is a member of.
    fn tuplets_containing(&self, event: EventId) -> Vec<TupletId> {
        self.score
            .cross_cutting
            .tuplets
            .iter()
            .filter(|tuplet| tuplet.members.contains(&event))
            .map(|tuplet| tuplet.id)
            .collect()
    }

    /// Whether `tuplet` is flat — not nested inside another and not the parent of one.
    /// Cascade-deleting a nested tuplet would leave the parent referencing tombstoned
    /// members, so make-room refuses it for now.
    fn is_flat_tuplet(&self, tuplet: TupletId) -> bool {
        let tuplets = &self.score.cross_cutting.tuplets;
        tuplets
            .iter()
            .find(|t| t.id == tuplet)
            .is_some_and(|t| t.parent.is_none())
            && !tuplets.iter().any(|t| t.parent == Some(tuplet))
    }

    /// `tuplet`'s live member events, in member order.
    fn tuplet_members(&self, tuplet: TupletId) -> Vec<EventId> {
        self.score
            .cross_cutting
            .tuplets
            .iter()
            .find(|t| t.id == tuplet)
            .map(|t| {
                t.members
                    .iter()
                    .copied()
                    .filter(|m| self.score.events.get(*m).is_some())
                    .collect()
            })
            .unwrap_or_default()
    }

    /// Turns a [`MakeRoom`] into operations: trims (`ModifyEvent`), deletes
    /// (`DeleteEvent`), and split tails (`InsertEvent`, cloning the original event's
    /// shape with fresh ids, carrying any authored spelling via `RespellPitch`). Order
    /// frees the span before the caller appends its own op (the new note or the resize).
    fn make_room_ops(
        &self,
        room: MakeRoom,
        staff_instance: StaffInstanceId,
        minter: &mut Minter,
    ) -> Vec<OperationKind> {
        let mut ops: Vec<OperationKind> = Vec::new();
        // Cascade-delete whole tuplets first, in order — the structure-removing delete
        // must precede the members that then delete as ordinary (no-longer-tuplet) events.
        for (event, tuplet_compensation) in room.cascade_deletes {
            ops.push(OperationKind::DeleteEvent(DeleteEventOp {
                event,
                tuplet_compensation,
            }));
        }
        for event in room.trims {
            ops.push(OperationKind::ModifyEvent(ModifyEventOp { event }));
        }
        for event in room.deletes {
            ops.push(OperationKind::DeleteEvent(DeleteEventOp {
                event,
                tuplet_compensation: TupletCompensation::NotInTuplet,
            }));
        }
        for (original, position, duration) in room.tails {
            // The tail is the original event's later portion: clone its whole shape (a
            // rest's visibility, a note's articulations/dynamics/stem/grace) with fresh
            // ids, then carry any *authored* spelling onto the fresh pitches (an inferred
            // one re-derives) — the same atomic copy `insert_note_after_selection` does.
            let mut pitches: Vec<&IdentifiedPitch> = Vec::new();
            original.collect_identified_pitches(&mut pitches);
            let originals: Vec<PitchId> = pitches.iter().map(|ip| ip.id).collect();
            let fresh: Vec<PitchId> = originals.iter().map(|_| minter.pitch()).collect();
            let tail = respan_with_fresh_ids(&original, minter.event(), position, duration, &fresh);
            ops.push(OperationKind::InsertEvent(InsertEventOp {
                staff_instance,
                event: tail,
            }));
            for (old, new) in originals.iter().zip(&fresh) {
                if let Some(spelling) = authored_spelling(&self.score, *old) {
                    ops.push(OperationKind::RespellPitch(RespellPitchOp {
                        pitch: *new,
                        spelling,
                    }));
                }
            }
        }
        ops
    }

    /// A fresh-id [`Minter`] seeded from the session high-water marks (which do not move
    /// until commit), so several mints within one intent never collide.
    fn minter(&self) -> Minter {
        Minter {
            replica: self.replica,
            next_event: self.mint_event_id().counter(),
            next_pitch: self.mint_pitch_id().counter(),
            next_slur: self.mint_slur_id().counter(),
            next_tie: self.mint_tie_id().counter(),
        }
    }

    /// Whether `event` is a member of any tuplet (its duration is ratio-governed, so
    /// make-room cannot trim or delete it without tuplet compensation).
    fn event_in_tuplet(&self, event: EventId) -> bool {
        self.score
            .cross_cutting
            .tuplets
            .iter()
            .any(|tuplet| tuplet.members.contains(&event))
    }

    /// Whether `event` carries a persistent decomposition attachment — its notated
    /// components, which a duration change would no longer sum to (invariant 15), so the
    /// editor cannot resize/trim it until decomposition-edit ops exist.
    fn event_has_decomposition(&self, event: EventId) -> bool {
        self.score
            .decomposition_attachments
            .iter()
            .any(|attachment| attachment.target == event)
    }

    /// The staff instance hosting `voice`, but only when its region is metric — the
    /// time model `InsertEvent` requires (the reducer rejects any other). `None` if
    /// the voice is absent from the voice tree or its region is non-metric.
    fn metric_staff_instance_of_voice(&self, voice: VoiceId) -> Option<StaffInstanceId> {
        let (region_id, staff_instance, _) = self.score.voices().find(|(_, _, v)| v.id == voice)?;
        let region = self
            .score
            .canvas
            .regions
            .iter()
            .find(|r| r.id == region_id)?;
        matches!(region.time_model, RegionTimeModel::Metric(_)).then_some(staff_instance)
    }

    /// Whether any metric event already in `voice` overlaps `[position, position +
    /// duration)` — the same voice-overlap test the reducer's insert applies, so a
    /// clean pre-check matches its accept/no-op decision exactly.
    fn voice_slot_occupied(
        &self,
        voice: VoiceId,
        position: &MusicalPosition,
        duration: &MusicalDuration,
    ) -> bool {
        self.score.events.iter().any(|ev| {
            ev.voice() == voice
                && matches!(
                    (ev.position(), ev.duration()),
                    (EventPosition::Musical(p), EventDuration::Musical(d))
                        if musical_overlap(position, duration, p, d)
                )
        })
    }

    /// Mints a fresh [`EventId`] in the session's replica namespace, on the same
    /// three-source high-water-mark basis as [`Self::mint_pitch_id`]: the pristine
    /// `base`, the current score (each live or tombstoned), and this session's
    /// **authored** history (so an id from an undone insert is never reused).
    fn mint_event_id(&self) -> EventId {
        let ids = self
            .base
            .events
            .iter()
            .map(Event::id)
            .chain(self.base.tombstoned_events.iter().copied())
            .chain(self.score.events.iter().map(Event::id))
            .chain(self.score.tombstoned_events.iter().copied())
            .chain(self.authored.iter().flat_map(inserted_event_ids));
        let next = ids
            .filter(|e| e.replica() == self.replica)
            .map(|e| e.counter())
            .max()
            .map_or(0, |c| {
                c.checked_add(1).expect("event id counter overflowed u64")
            });
        EventId::new(self.replica, next)
    }

    /// The current value of the identified `pitch` in the score graph, if it is
    /// present (a live embedded pitch).
    fn current_pitch(&self, pitch: PitchId) -> Option<Pitch> {
        self.event_and_pitch_of(pitch).map(|(_, value)| value)
    }

    /// The event holding the identified `pitch` and the pitch's current value, if it
    /// is present (a live embedded pitch).
    fn event_and_pitch_of(&self, pitch: PitchId) -> Option<(EventId, Pitch)> {
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        for event in self.score.events.iter() {
            buf.clear();
            event.collect_identified_pitches(&mut buf);
            if let Some(ip) = buf.iter().find(|ip| ip.id == pitch) {
                return Some((event.id(), ip.pitch.clone()));
            }
        }
        None
    }

    /// Mints a fresh [`PitchId`] in the session's replica namespace: one past the
    /// highest pitch counter this replica has ever named. A pitch can leave the
    /// *current* score without being recorded anywhere in it — `DeleteIdentifiedPitch`
    /// tombstones only reducer state, never `Score.tombstoned_pitches` — so the
    /// high-water mark is taken over three sources: the pristine open-time `base`
    /// (catches an opened pitch since deleted), the current score (catches anything a
    /// future reducer change records), and this session's **authored** history (catches
    /// a session-inserted pitch since deleted *or undone*). Reusing an id would make a
    /// later insert no-op against a tombstone under whole-log reduction. Pitches authored
    /// by other replicas occupy disjoint namespaces and do not constrain it.
    fn mint_pitch_id(&self) -> PitchId {
        let ids = self
            .base
            .live_pitch_ids()
            .into_iter()
            .chain(self.base.tombstoned_pitches.iter().copied())
            .chain(self.score.live_pitch_ids())
            .chain(self.score.tombstoned_pitches.iter().copied())
            .chain(self.authored.iter().flat_map(inserted_pitch_ids));
        let next = ids
            .filter(|p| p.replica() == self.replica)
            .map(|p| p.counter())
            .max()
            .map_or(0, |c| {
                c.checked_add(1).expect("pitch id counter overflowed u64")
            });
        PitchId::new(self.replica, next)
    }

    /// Mints a fresh [`SlurId`] in the session's replica namespace, on the same
    /// high-water-mark discipline as [`Self::mint_pitch_id`] — the pristine `base`, the
    /// current score, and this session's **authored** history (so a since-deleted or
    /// since-undone slur's id is never reused). Unlike a pitch, a deleted slur leaves
    /// no separate tombstone bookkeeping to chain (`DeleteCrossCutting` only flips the
    /// reducer's transient `objects` map, which this session does not retain), so the
    /// authored-history source is what actually carries a deleted/undone slur's id
    /// forward here.
    fn mint_slur_id(&self) -> SlurId {
        let ids = self
            .base
            .cross_cutting
            .slurs
            .iter()
            .map(|s| s.id)
            .chain(self.score.cross_cutting.slurs.iter().map(|s| s.id))
            .chain(self.authored.iter().flat_map(inserted_slur_ids));
        let next = ids
            .filter(|s| s.replica() == self.replica)
            .map(|s| s.counter())
            .max()
            .map_or(0, |c| {
                c.checked_add(1).expect("slur id counter overflowed u64")
            });
        SlurId::new(self.replica, next)
    }

    /// Mints a fresh [`TieId`], the tie analogue of [`Self::mint_slur_id`] (same
    /// discipline, same rationale).
    fn mint_tie_id(&self) -> TieId {
        let ids = self
            .base
            .cross_cutting
            .ties
            .iter()
            .map(|t| t.id)
            .chain(self.score.cross_cutting.ties.iter().map(|t| t.id))
            .chain(self.authored.iter().flat_map(inserted_tie_ids));
        let next = ids
            .filter(|t| t.replica() == self.replica)
            .map(|t| t.counter())
            .max()
            .map_or(0, |c| {
                c.checked_add(1).expect("tie id counter overflowed u64")
            });
        TieId::new(self.replica, next)
    }

    /// Re-resolves the whole selection set against the current layout — see
    /// [`SelectionSet::reresolve`] for the member-survival and anchor-fallback
    /// rules. Returns whether the anchor's own member survived unchanged.
    fn reresolve_selection(&mut self) -> bool {
        self.selection.reresolve(&self.map)
    }

    /// Re-resolves the caret against the current score (contract:
    /// `spec/CONTRACT_EDITOR_T3_CARET.md` §W1 — "if its voice ceases to exist,
    /// the caret clears"; called alongside [`Self::reresolve_selection`] after
    /// every apply/undo/redo). Unlike the selection set's survivor rule, a point
    /// cursor has no fallback: a vanished voice simply clears the caret. Nothing
    /// else here relocates the caret — undo does not move it (the contract's
    /// own pin), and this method only ever clears, never repositions.
    fn reresolve_caret(&mut self) {
        if let Some(caret) = &self.caret {
            if self.score.voices().all(|(_, _, v)| v.id != caret.voice) {
                self.caret = None;
            }
        }
    }

    /// The reference pitch [`Self::enter_nominal`]'s octave inference measures
    /// from: the nearest preceding **note** (a rest does not count — it has no
    /// pitch) in `voice`, strictly before `position` — the closest earlier
    /// onset — as its CMN nominal/octave (the alteration is dropped: only the
    /// staff position matters for the nearest-in-steps rule). `None` with no
    /// earlier note, or when the nearest one is not a CMN pitch (there is then
    /// no staff-step notion to measure from) — both fall back to
    /// [`infer_octave`]'s default octave 4.
    ///
    /// A chord's **first** pitch is the reference; the contract names no
    /// chord tie-break, so this is a documented, arbitrary pick (`DECISIONS.md`).
    fn reference_pitch_before(
        &self,
        voice: VoiceId,
        position: &MusicalPosition,
    ) -> Option<(CmnNominal, i8)> {
        let mut best: Option<(MusicalPosition, Pitch)> = None;
        for event in self.score.events.iter() {
            if event.voice() != voice {
                continue;
            }
            let Event::Pitched(pe) = event else {
                continue;
            };
            let EventPosition::Musical(at) = &pe.position else {
                continue;
            };
            if at >= position {
                continue;
            }
            let Some(first) = pe.pitches.first() else {
                continue;
            };
            let better = match &best {
                None => true,
                Some((best_at, _)) => at > best_at,
            };
            if better {
                best = Some((at.clone(), first.pitch.clone()));
            }
        }
        best.and_then(|(_, pitch)| match pitch.scale_position.position {
            PitchSpacePosition::Cmn {
                nominal, octave, ..
            } => Some((nominal, octave)),
            _ => None,
        })
    }
}

/// The pitch one or more diatonic **staff steps** from `pitch`: the CMN nominal is
/// moved by `steps`, carrying the octave at the B↔C boundary, with the alteration and
/// acoustic realization preserved (a pure staff-position move). Returns `None` for a
/// non-CMN position — which has no staff-step notion — or an octave that overflows
/// `i8`.
fn staff_step(pitch: &Pitch, steps: i32) -> Option<Pitch> {
    let PitchSpacePosition::Cmn { alteration, .. } = pitch.scale_position.position else {
        return None;
    };
    // Seven nominals to an octave: index diatonically, move, then decompose so a
    // move past B (or below C) carries the octave. Computed in i64 so an extreme
    // `steps` cannot overflow the intermediate before the octave range-check below.
    let diatonic = diatonic_index(pitch)? + steps as i64;
    let new_octave = i8::try_from(diatonic.div_euclid(7)).ok()?;
    let new_nominal = nominal_from_index(diatonic.rem_euclid(7));
    let mut moved = pitch.clone();
    moved.scale_position.position = PitchSpacePosition::Cmn {
        nominal: new_nominal,
        alteration,
        octave: new_octave,
    };
    Some(moved)
}

/// The octave [`EditorSession::enter_nominal`] infers for `nominal`: the one
/// that puts it nearest, in diatonic **staff steps**, to `reference` (its
/// nominal + octave), or octave 4 with no reference. Equidistant ties resolve
/// **downward** (contract-pinned: `spec/CONTRACT_EDITOR_T3_CARET.md` §W1) via
/// [`nearer_is_down`].
///
/// **No (reference, nominal) pair drawn from the seven CMN letters can ever
/// tie**, provably: the candidate octaves for a fixed `nominal` are exactly 7
/// diatonic steps apart, so the two flanking distances from `reference` are `r`
/// and `7 - r` for some integer `r` in `0..7`; these are equal only at `r =
/// 3.5`, unreachable for an integer. `nearer_is_down` is still exercised
/// directly, with a synthetic tied input, to prove the written comparison
/// itself rather than leave it dead code no real table row can reach
/// (`DECISIONS.md` — this is a documented finding, not an assumption).
fn infer_octave(reference: Option<(CmnNominal, i8)>, nominal: CmnNominal) -> i8 {
    let Some((ref_nominal, ref_octave)) = reference else {
        return 4;
    };
    let ref_index = ref_octave as i64 * 7 + ref_nominal as i64;
    let nominal_val = nominal as i64;
    // `diff = ref_index - nominal_val = 7*q + r` (`0 <= r < 7`, `div_euclid`/
    // `rem_euclid`): the down candidate is octave `q` (distance `r`, at or below
    // the reference), the up candidate is octave `q + 1` (distance `7 - r`).
    let diff = ref_index - nominal_val;
    let r = diff.rem_euclid(7);
    let q = diff.div_euclid(7);
    let octave = if nearer_is_down(r, 7 - r) { q } else { q + 1 };
    octave.clamp(i8::MIN as i64, i8::MAX as i64) as i8
}

/// The tie-break [`infer_octave`] applies when a candidate a diatonic-step
/// distance of `down_distance` **below** the reference ties with one
/// `up_distance` **above** it: downward wins — `<=`, not `<`, so an equal-
/// distance tie resolves down while a strictly nearer "up" candidate still
/// wins on its own merits. See [`infer_octave`]'s doc for why no real
/// (reference, nominal) pair ever calls this with equal arguments.
fn nearer_is_down(down_distance: i64, up_distance: i64) -> bool {
    down_distance <= up_distance
}

/// A 5-line staff spans four staff spaces above its bottom line.
const STAFF_SPAN: f32 = 4.0;

/// The distance from height `y` to a staff's line band `(bottom, top)`: zero inside
/// the band, else the gap to the nearer edge. Used to pick the staff a click is over.
fn dist_to_band(y: f32, (bottom, top): (f32, f32)) -> f32 {
    if y < bottom {
        bottom - y
    } else if y > top {
        y - top
    } else {
        0.0
    }
}

/// Whether a resolved bounding box carries **real** cast-off geometry: finite
/// origin and strictly positive extent on both axes. A solver that does not cast
/// off (the stub) emits `Rect::default()` — zero-size — boxes, which must not
/// capture clicks; the callers fall back to the flat single-system paths instead.
fn rect_is_real(rect: &Rect) -> bool {
    let width = rect.size.width.0;
    let height = rect.size.height.0;
    rect.origin.x.0.is_finite()
        && rect.origin.y.0.is_finite()
        && width.is_finite()
        && height.is_finite()
        && width > 0.0
        && height > 0.0
}

/// A rect's vertical band as `(bottom, top)`, the shape [`dist_to_band`] takes.
fn rect_y_band(rect: &Rect) -> (f32, f32) {
    (rect.origin.y.0, rect.origin.y.0 + rect.size.height.0)
}

/// Whether `point` lies within `rect`, edges included (a glyph exactly on a
/// system's edge belongs to that system).
fn rect_contains(rect: &Rect, point: Point) -> bool {
    point.x.0 >= rect.origin.x.0
        && point.x.0 <= rect.origin.x.0 + rect.size.width.0
        && point.y.0 >= rect.origin.y.0
        && point.y.0 <= rect.origin.y.0 + rect.size.height.0
}

/// Inverts an `x` coordinate to a raw musical position through `(onset, x)` anchors
/// in ascending order (`>= 2`, leftmost first) — the horizontal inverse before grid
/// snapping. Within the anchored span it interpolates the bracketing segment; outside
/// it, it extrapolates the nearest end segment's slope (the common case — a click in
/// the empty staff after the last note). `f64` because this is geometry, not exact
/// musical time; the result is snapped to an exact grid position by [`snap_to_grid`].
fn invert_x(anchors: &[(MusicalPosition, f32)], x: f32) -> f64 {
    let n = anchors.len();
    debug_assert!(n >= 2, "invert_x needs at least two anchors for a scale");
    let pos = |i: usize| anchors[i].0 .0.to_f64();
    let ax = |i: usize| anchors[i].1 as f64;
    // The segment to (inter/extra)polate on: the one bracketing `x`, clamped to the
    // first/last segment when `x` is left of / right of every anchor.
    let seg = if x <= anchors[0].1 {
        0
    } else if x >= anchors[n - 1].1 {
        n - 2
    } else {
        (0..n - 1)
            .find(|&i| x >= anchors[i].1 && x <= anchors[i + 1].1)
            .unwrap_or(n - 2)
    };
    let (x0, x1) = (ax(seg), ax(seg + 1));
    let (p0, p1) = (pos(seg), pos(seg + 1));
    let span = x1 - x0;
    if span.abs() < f64::EPSILON {
        return p0;
    }
    p0 + (x as f64 - x0) / span * (p1 - p0)
}

/// Inverts a musical `position` to a world `x` through `(onset, x)` anchors in
/// ascending order (`>= 2`, leftmost first) — [`EditorSession::x_at_position`]'s
/// engine, and the exact mirror of [`invert_x`] with the roles of `x` and musical
/// time swapped: within the anchored span it interpolates the bracketing segment;
/// outside it, it extrapolates the nearest end segment's slope. `f64` for the same
/// reason `invert_x` is: this is geometry, not exact musical time.
fn forward_x(anchors: &[(MusicalPosition, f32)], position: &MusicalPosition) -> f32 {
    let n = anchors.len();
    debug_assert!(n >= 2, "forward_x needs at least two anchors for a scale");
    let onset = |i: usize| anchors[i].0 .0.to_f64();
    let x = |i: usize| anchors[i].1 as f64;
    let target = position.0.to_f64();
    // The segment to (inter/extra)polate on: the one bracketing `target`, clamped
    // to the first/last segment when `target` is before / after every anchor.
    let seg = if target <= onset(0) {
        0
    } else if target >= onset(n - 1) {
        n - 2
    } else {
        (0..n - 1)
            .find(|&i| target >= onset(i) && target <= onset(i + 1))
            .unwrap_or(n - 2)
    };
    let (p0, p1) = (onset(seg), onset(seg + 1));
    let (x0, x1) = (x(seg), x(seg + 1));
    let span = p1 - p0;
    let result = if span.abs() < f64::EPSILON {
        x0
    } else {
        x0 + (target - p0) / span * (x1 - x0)
    };
    result as f32
}

/// Snaps a raw musical position to the nearest multiple of `step` from the origin,
/// clamped to be non-negative (no position precedes the region start). The multiple
/// is taken in `f64`, then the position is rebuilt by exact rational arithmetic
/// (`step * k`), so the result lands exactly on the grid, not on a rounded float.
fn snap_to_grid(raw: f64, step: &MusicalDuration) -> MusicalPosition {
    let step_f = step.0.to_f64();
    let k = if step_f > 0.0 {
        (raw / step_f).round().clamp(0.0, i32::MAX as f64) as i32
    } else {
        0
    };
    MusicalPosition(step.0.mul(&RationalTime::from_int(k)))
}

/// [`EditorSession::retreat`]'s clamped subtraction: `position` moved back by
/// `step`, clamped to the region origin (never negative). There is no
/// `MusicalPosition - MusicalDuration` in the type-level algebra of Chapter 3
/// (`crate::time`'s module doc: "position + position is not defined" — and
/// symmetrically, an unclamped position minus a duration could go negative,
/// which is not a valid position either), so this reimplements the subtraction
/// directly over the raw [`RationalTime`], clamping the result.
fn retreat_position(position: &MusicalPosition, step: &MusicalDuration) -> MusicalPosition {
    let raw = position.0.sub(&step.0);
    if raw.is_negative() {
        MusicalPosition::origin()
    } else {
        MusicalPosition(raw)
    }
}

/// The diatonic staff index of a CMN pitch — `octave * 7 + nominal`, so two pitches
/// compare by staff position and a step is `± 1`. `None` for a non-CMN position.
fn diatonic_index(pitch: &Pitch) -> Option<i64> {
    match pitch.scale_position.position {
        PitchSpacePosition::Cmn {
            nominal, octave, ..
        } => Some(octave as i64 * 7 + nominal as i64),
        _ => None,
    }
}

/// The diatonic staff index of a resolved CMN spelling — `octave * 7 + nominal`, on
/// the same scale as [`diatonic_index`], so a note's resolved (rendered) staff position
/// and a raw pitch position are directly comparable. `None` for a non-CMN nominal.
fn spelling_diatonic(spelling: &PitchSpelling) -> Option<i64> {
    match spelling.nominal {
        SpellingNominal::Cmn(nominal) => Some(spelling.octave as i64 * 7 + nominal as i64),
        _ => None,
    }
}

/// The **rendered** staff index of `pitch` (id `id`): from its resolved spelling when
/// that is CMN — so an authored override or an inferred respelling is reflected —
/// otherwise its raw pitch position.
fn resolved_staff_index(
    annotations: &DerivedAnnotations,
    id: PitchId,
    pitch: &Pitch,
) -> Option<i64> {
    annotations
        .spellings
        .get(&id)
        .and_then(|resolved| spelling_diatonic(&resolved.spelling))
        .or_else(|| diatonic_index(pitch))
}

/// `top` moved `steps` diatonic staff steps (preserving its alteration), with the
/// acoustic realization reset to [`AcousticRealization::Implicit`]. A fresh note must
/// sound at its written position; cloning an explicit absolute-Hz or cents-offset
/// realization from the note it stacks above would make it look higher but sound the
/// same frequency. `None` for a non-CMN position.
fn note_stepped(top: &Pitch, steps: i32) -> Option<Pitch> {
    let mut moved = staff_step(top, steps)?;
    moved.acoustic.realization = AcousticRealization::Implicit;
    Some(moved)
}

/// A natural CMN pitch at `nominal`/`octave`, sounding at its written position
/// ([`AcousticRealization::Implicit`]) — the value a click-to-insert mints for the
/// height under the cursor (a caller respells if an accidental is wanted), and
/// [`EditorSession::enter_nominal`]'s own insertion value.
fn cmn_pitch(nominal: CmnNominal, octave: i8) -> Pitch {
    chromatic_pitch(nominal, 0, octave)
}

/// A CMN pitch at `nominal`/`alteration`/`octave`, sounding at its written
/// position ([`AcousticRealization::Implicit`]) — the alteration generalization
/// of [`cmn_pitch`] (naturals only); [`midi_note_to_pitch`] is its only
/// non-zero-alteration caller today.
fn chromatic_pitch(nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch {
    Pitch {
        scale_position: ScalePosition {
            space: PitchSpaceId::new("cmn-12"),
            position: PitchSpacePosition::Cmn {
                nominal,
                alteration,
                octave,
            },
        },
        acoustic: AcousticPitch {
            tuning: TuningReference::Inherit,
            realization: AcousticRealization::Implicit,
        },
    }
}

/// The v1 MIDI-to-pitch policy [`EditorSession::enter_pitch`] takes note-on
/// events through (contract: `spec/CONTRACT_EDITOR_T3_CARET.md` §W1 — the
/// step-time MIDI path `spec/PLAN_EDITOR_APP.md`'s T3 names as the primary entry
/// method, wired app-side onto this pure seam): scientific-pitch octave
/// numbering (`60 = C4`, `69 = A4`) and every black key spelled **sharp**, never
/// flat — a later spelling prepass may refine the enharmonic choice; this is
/// deliberately the raw, un-key-aware mapping.
pub fn midi_note_to_pitch(note: u8) -> Pitch {
    const CHROMATIC: [(CmnNominal, i8); 12] = [
        (CmnNominal::C, 0),
        (CmnNominal::C, 1),
        (CmnNominal::D, 0),
        (CmnNominal::D, 1),
        (CmnNominal::E, 0),
        (CmnNominal::F, 0),
        (CmnNominal::F, 1),
        (CmnNominal::G, 0),
        (CmnNominal::G, 1),
        (CmnNominal::A, 0),
        (CmnNominal::A, 1),
        (CmnNominal::B, 0),
    ];
    let note = note as i32;
    let octave = (note / 12 - 1) as i8;
    let (nominal, alteration) = CHROMATIC[(note % 12) as usize];
    chromatic_pitch(nominal, alteration, octave)
}

/// The musical duration spanning `from..to` (`to - from`), exact over rational time.
fn span_between(from: &MusicalPosition, to: &MusicalPosition) -> MusicalDuration {
    MusicalDuration(to.0.sub(&from.0))
}

/// A time signature's beat unit — the note value it counts in, `1/denominator` (4/4 → a
/// quarter, 6/8 → an eighth). `None` for a meter with no single denominator
/// (mixed-denominator, none, or a symbolic display), which the caller defaults.
fn time_signature_beat(ts: &TimeSignature) -> Option<MusicalDuration> {
    let denominator: i64 = match &ts.display {
        TimeSignatureDisplay::Standard { denominator, .. }
        | TimeSignatureDisplay::Compound { denominator, .. } => denominator.get() as i64,
        TimeSignatureDisplay::Irrational { denominator, .. } => denominator.get() as i64,
        TimeSignatureDisplay::MixedDenominators { .. }
        | TimeSignatureDisplay::None
        | TimeSignatureDisplay::Symbolic(_) => return None,
    };
    RationalTime::new(1, denominator).map(MusicalDuration)
}

/// The events make-room must change to clear a span: whole-event deletes, in-place
/// trims (a `ModifyEvent` value), and splits (the original event plus the tail's onset
/// and duration, for re-inserting the tail with fresh ids). Built by
/// [`EditorSession::make_room`], turned into ops by [`EditorSession::make_room_ops`].
#[derive(Default)]
struct MakeRoom {
    trims: Vec<Event>,
    deletes: Vec<EventId>,
    tails: Vec<(Event, MusicalPosition, MusicalDuration)>,
    /// Whole-tuplet cascade deletes: every member of each overlapped tuplet, paired with
    /// its delete compensation. The first member of a tuplet carries
    /// [`TupletCompensation::CascadeDeleteTuplets`] (which removes the tuplet structure);
    /// the rest are then ordinary [`NotInTuplet`](TupletCompensation::NotInTuplet)
    /// deletes, so they must apply in this order.
    cascade_deletes: Vec<(EventId, TupletCompensation)>,
}

/// Mints fresh event/pitch ids within one intent, advancing local counters (checked,
/// like the session minters) so the several mints in one transaction never collide —
/// the session high-water mark does not move until commit. Seeded by
/// [`EditorSession::minter`].
struct Minter {
    replica: ReplicaId,
    next_event: u64,
    next_pitch: u64,
    next_slur: u64,
    next_tie: u64,
}

impl Minter {
    fn event(&mut self) -> EventId {
        let id = EventId::new(self.replica, self.next_event);
        self.next_event = self
            .next_event
            .checked_add(1)
            .expect("event id counter overflowed u64");
        id
    }

    fn pitch(&mut self) -> PitchId {
        let id = PitchId::new(self.replica, self.next_pitch);
        self.next_pitch = self
            .next_pitch
            .checked_add(1)
            .expect("pitch id counter overflowed u64");
        id
    }

    fn slur(&mut self) -> SlurId {
        let id = SlurId::new(self.replica, self.next_slur);
        self.next_slur = self
            .next_slur
            .checked_add(1)
            .expect("slur id counter overflowed u64");
        id
    }

    fn tie(&mut self) -> TieId {
        let id = TieId::new(self.replica, self.next_tie);
        self.next_tie = self
            .next_tie
            .checked_add(1)
            .expect("tie id counter overflowed u64");
        id
    }
}

/// `event` re-placed at a new metric `position`/`duration` (a make-room trim), keeping
/// everything else. Only notes and rests carry a make-room rule; other kinds return
/// unchanged (the caller refuses such an overlap before reaching here).
fn replace_span(event: &Event, position: MusicalPosition, duration: MusicalDuration) -> Event {
    let mut moved = event.clone();
    match &mut moved {
        Event::Pitched(pe) => {
            pe.position = EventPosition::Musical(position);
            pe.duration = EventDuration::Musical(duration);
        }
        Event::Rest(rest) => {
            rest.position = EventPosition::Musical(position);
            rest.duration = EventDuration::Musical(duration);
        }
        _ => {}
    }
    moved
}

/// `event`'s later portion as a fresh event for a split tail: a clone with a fresh
/// event `id`, its pitches re-identified by `fresh_pitch_ids` (same values, in order),
/// re-placed at `position`/`duration`. Cloning keeps everything a continuation should
/// keep — a rest's `visible`/`vertical_position`, a note's articulations, dynamics,
/// ornaments, stem, and grace. Authored spellings are carried separately (they key on
/// pitch id, so the caller adds `RespellPitch`es for the fresh ids).
fn respan_with_fresh_ids(
    event: &Event,
    id: EventId,
    position: MusicalPosition,
    duration: MusicalDuration,
    fresh_pitch_ids: &[PitchId],
) -> Event {
    let mut tail = event.clone();
    let position = EventPosition::Musical(position);
    let duration = EventDuration::Musical(duration);
    match &mut tail {
        Event::Pitched(pe) => {
            pe.id = id;
            pe.position = position;
            pe.duration = duration;
            for (ip, fresh) in pe.pitches.iter_mut().zip(fresh_pitch_ids) {
                ip.id = *fresh;
            }
        }
        Event::Rest(rest) => {
            rest.id = id;
            rest.position = position;
            rest.duration = duration;
        }
        _ => {}
    }
    tail
}

/// A fresh metric event in `voice`: a [`PitchedEvent`] when `pitches` is non-empty,
/// else a [`Rest`](epiphany_core::Rest) — used for the new note and any split tail.
fn note_event(
    id: EventId,
    voice: VoiceId,
    position: MusicalPosition,
    duration: MusicalDuration,
    pitches: Vec<IdentifiedPitch>,
) -> Event {
    let position = EventPosition::Musical(position);
    let duration = EventDuration::Musical(duration);
    if pitches.is_empty() {
        Event::Rest(epiphany_core::Rest {
            id,
            voice,
            position,
            duration,
            vertical_position: None,
            visible: true,
        })
    } else {
        Event::Pitched(PitchedEvent {
            id,
            voice,
            position,
            duration,
            pitches,
            articulations: Vec::new(),
            dynamic: None,
            ornaments: Vec::new(),
            stem: StemConfiguration,
            grace: None,
        })
    }
}

/// Builds the [`Event`] a pasted fragment event mints, plus the
/// `RespellPitch` op for every pitch that carried an authored spelling
/// override — the insert precedes its own respells in the returned `Vec`, so
/// appending them in order to a transaction's op list gives every respell
/// the pitch it targets (the same discipline `EditorSession::make_room_ops`'s
/// split-tail loop uses). A free function, not a method: it reads nothing
/// from a session, only `fe` and the fresh ids `minter` mints.
fn fragment_event_to_op_event(
    id: EventId,
    voice: VoiceId,
    position: MusicalPosition,
    fe: &fragment::FragmentEvent,
    minter: &mut Minter,
) -> (Event, Vec<OperationKind>) {
    let position = EventPosition::Musical(position);
    let duration = EventDuration::Musical(fe.duration.clone());
    match &fe.content {
        fragment::FragmentEventContent::Rest {
            vertical_position,
            visible,
        } => (
            Event::Rest(epiphany_core::Rest {
                id,
                voice,
                position,
                duration,
                vertical_position: *vertical_position,
                visible: *visible,
            }),
            Vec::new(),
        ),
        fragment::FragmentEventContent::Pitched {
            pitches,
            articulations,
            dynamic,
            ornaments,
            stem,
            grace,
        } => {
            let mut respells = Vec::with_capacity(pitches.len());
            let identified: Vec<IdentifiedPitch> = pitches
                .iter()
                .map(|fp| {
                    let pid = minter.pitch();
                    if let Some(spelling) = &fp.spelling_override {
                        respells.push(OperationKind::RespellPitch(RespellPitchOp {
                            pitch: pid,
                            spelling: spelling.clone(),
                        }));
                    }
                    IdentifiedPitch {
                        id: pid,
                        pitch: fp.pitch.clone(),
                    }
                })
                .collect();
            (
                Event::Pitched(PitchedEvent {
                    id,
                    voice,
                    position,
                    duration,
                    pitches: identified,
                    articulations: articulations.clone(),
                    dynamic: dynamic.clone(),
                    ornaments: ornaments.clone(),
                    stem: stem.clone(),
                    grace: grace.clone(),
                }),
                respells,
            )
        }
    }
}

/// The pitch ids a session envelope brought into being — so the minter never reuses
/// one, including ids since deleted (a `DeleteIdentifiedPitch` leaves no trace in the
/// materialized score, so the log is the authoritative record). Both insert ops mint
/// pitches: `InsertIdentifiedPitch` (one) and `InsertEvent` (the event's embedded
/// pitches).
fn inserted_pitch_ids(env: &OperationEnvelope) -> Vec<PitchId> {
    match &env.payload {
        OperationPayload::Primitive(OperationKind::InsertIdentifiedPitch(op)) => vec![op.pitch.id],
        OperationPayload::Primitive(OperationKind::InsertEvent(op)) => op.pitch_ids(),
        _ => Vec::new(),
    }
}

/// The event ids a session envelope brought into being — the event-id analogue of
/// [`inserted_pitch_ids`], so the event minter never reuses a since-deleted id.
fn inserted_event_ids(env: &OperationEnvelope) -> Vec<EventId> {
    match &env.payload {
        OperationPayload::Primitive(OperationKind::InsertEvent(op)) => vec![op.event_id()],
        _ => Vec::new(),
    }
}

/// The slur ids a session envelope brought into being — the slur-id analogue of
/// [`inserted_pitch_ids`], so [`EditorSession::mint_slur_id`] never reuses a
/// since-deleted or since-undone one.
fn inserted_slur_ids(env: &OperationEnvelope) -> Vec<SlurId> {
    match &env.payload {
        OperationPayload::Primitive(OperationKind::CreateCrossCutting(op)) => match &op.structure {
            CrossCuttingValue::Slur(slur) => vec![slur.id],
            _ => Vec::new(),
        },
        _ => Vec::new(),
    }
}

/// The tie ids a session envelope brought into being — the tie analogue of
/// [`inserted_slur_ids`].
fn inserted_tie_ids(env: &OperationEnvelope) -> Vec<TieId> {
    match &env.payload {
        OperationPayload::Primitive(OperationKind::CreateCrossCutting(op)) => match &op.structure {
            CrossCuttingValue::Tie(tie) => vec![tie.id],
            _ => Vec::new(),
        },
        _ => Vec::new(),
    }
}

/// The transaction id a session envelope declares, if it is a `DeclareTransaction`.
fn declared_transaction_id(env: &OperationEnvelope) -> Option<TransactionId> {
    match &env.payload {
        OperationPayload::Primitive(OperationKind::DeclareTransaction(desc)) => Some(desc.id),
        _ => None,
    }
}

/// Extends a causal context to also cover `op`. When `op` continues `op.replica`'s
/// contiguous range (the common case — a session with no undo mints `0, 1, 2, …`), it
/// folds into the compact version vector, reproducing the simple `with_seen(replica,
/// counter)` history; once a fork (undo + new edit) leaves a gap below `op.counter`, it
/// is recorded as an individual dot so the context covers the active op without
/// asserting — and stranding the new op pending behind — the forked-away counters in
/// between.
fn extend_context(context: CausalContext, op: OperationId) -> CausalContext {
    let continues = context
        .vector
        .get(&op.replica)
        .map_or(op.counter == 0, |&high| op.counter == high + 1);
    if continues {
        context.with_seen(op.replica, op.counter)
    } else {
        context.with_dot(op)
    }
}

/// Whether two metric spans overlap — `[a_pos, a_pos + a_dur)` against `[b_pos,
/// b_pos + b_dur)`. Mirrors the reducer's `intervals_overlap` (non-positive spans
/// never overlap), so the editor's pre-check matches the reducer's decision.
fn musical_overlap(
    a_pos: &MusicalPosition,
    a_dur: &MusicalDuration,
    b_pos: &MusicalPosition,
    b_dur: &MusicalDuration,
) -> bool {
    if !a_dur.is_positive() || !b_dur.is_positive() {
        return false;
    }
    let a_end = a_pos.clone() + a_dur.clone();
    let b_end = b_pos.clone() + b_dur.clone();
    *a_pos < b_end && *b_pos < a_end
}

/// The CMN nominal at diatonic index `i` (`0..=6` → `C..=B`); callers pass
/// `rem_euclid(7)`, so other values are unreachable and fold to `B`.
fn nominal_from_index(i: i64) -> CmnNominal {
    match i {
        0 => CmnNominal::C,
        1 => CmnNominal::D,
        2 => CmnNominal::E,
        3 => CmnNominal::F,
        4 => CmnNominal::G,
        5 => CmnNominal::A,
        _ => CmnNominal::B,
    }
}

/// The authored explicit spelling the resolver would use for `pitch` — the one that
/// pins its rendered staff position — or `None` if there is none. Mirrors
/// `epiphany_core`'s `resolve_spelling`: among engraved-layer, pitch-scoped, explicit
/// attachments whose source outranks `Inferred` under the score's precedence (the
/// default ranks `UserChosen`, `Imported`, and `Propagated` all ahead of `Inferred`),
/// the winner is the lowest precedence rank, then highest priority, then first in
/// canonical order.
fn authored_spelling(score: &Score, pitch: PitchId) -> Option<PitchSpelling> {
    let inferred_rank = score.spelling_precedence.rank(SpellingSourceKind::Inferred);
    score
        .spelling_attachments
        .iter()
        .filter(|att| {
            att.layer.is_none()
                && matches!(&att.scope, SpellingScope::Pitch(p) if *p == pitch)
                && matches!(att.directive, SpellingDirective::Explicit(_))
                && score.spelling_precedence.rank(att.source.kind()) < inferred_rank
        })
        .min_by_key(|att| {
            (
                score.spelling_precedence.rank(att.source.kind()),
                Reverse(att.priority),
            )
        })
        .and_then(|att| match &att.directive {
            SpellingDirective::Explicit(spelling) => Some(spelling.clone()),
            _ => None,
        })
}

/// The spelling one or more diatonic staff steps from `spelling`: its CMN nominal
/// moves by `steps` (carrying the octave at the B↔C boundary), with the accidental
/// stack and render hints preserved. `None` for a non-CMN spelling nominal. The
/// spelling analogue of [`staff_step`], used to rebase an override as a pitch moves.
fn staff_step_spelling(spelling: &PitchSpelling, steps: i32) -> Option<PitchSpelling> {
    let SpellingNominal::Cmn(nominal) = spelling.nominal else {
        return None;
    };
    let diatonic = spelling.octave as i64 * 7 + nominal as i64 + steps as i64;
    let new_octave = i8::try_from(diatonic.div_euclid(7)).ok()?;
    Some(PitchSpelling {
        nominal: SpellingNominal::Cmn(nominal_from_index(diatonic.rem_euclid(7))),
        accidentals: spelling.accidentals.clone(),
        octave: new_octave,
        render_hints: spelling.render_hints,
    })
}

/// Renders a score with `solver` to its `RenderIR` + hit-test map, or `None` if the
/// solver's report is diagnostic-only (not renderable).
type StartClefs = BTreeMap<(RegionId, StaffId), Clef>;

fn render_score(
    score: &Score,
    solver: &dyn ConstraintSolver,
) -> Option<(StartClefs, ResolvedLayoutIR, RenderIR, HitTestMap)> {
    // Build the start-clef table from the logical layout, where anchor resolution has
    // already placed each `PlacedClef` at a concrete time — the editor's vertical
    // inverse spells the clicked height against this, not against vector order.
    let logical = to_logical(score);
    let start_clefs = staff_start_clefs(&logical);
    let report = solver.solve(&to_constrained(&logical), &SolverConfig::default());
    if !report.status.is_renderable() {
        return None;
    }
    let render = to_render(&report.layout);
    let map = render.hit_test_map();
    Some((start_clefs, report.layout, render, map))
}

/// The clef in force at each manifested staff's start, resolved by time from the
/// logical layout's placed clefs (see [`active_clef`]). Keyed by the manifesting
/// `(region, staff)` so tiled copies of one staff each get their own entry — the
/// vertical half of click-to-insert looks a staff up by the region its rendered
/// lines were traced to.
fn staff_start_clefs(logical: &LogicalLayoutIR) -> StartClefs {
    let start = TimePoint::Musical(MusicalPosition::origin());
    let mut clefs = StartClefs::new();
    for region in &logical.regions {
        let TypedObjectId::Region(region_id) = region.provenance.source else {
            continue;
        };
        for object in &region.objects {
            if let (Some(staff), LayoutContent::Staff(content)) = (object.staff(), object.content())
            {
                // The staff's own default backs an empty (or later-starting)
                // clef sequence, exactly as the projection resolves it — else a
                // click on a bass staff would resolve its pitch as treble.
                clefs.insert(
                    (region_id, staff),
                    active_clef_or(&content.clefs, &start, content.default_clef),
                );
            }
        }
    }
    clefs
}

#[cfg(test)]
mod tests {
    use super::*;
    use epiphany_core::generators::{valid_score, valid_score_rich};
    // The frozen `Transpose` is never authored by the editor, but a peer may
    // still send one, so the barrier tests below construct it directly.
    use epiphany_layout_ir::{
        ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState,
        SolverTier, SolverVersion, StubSolver,
    };
    use epiphany_ops::TransposeOp;

    fn open_rich(seed: u64) -> EditorSession {
        EditorSession::open(valid_score_rich(seed), Box::new(StubSolver)).expect("rich renders")
    }

    /// A session on the plain fixture, whose single metric region is a tuplet-free run
    /// of quarter notes — the clean target for the make-room tests (the rich fixture's
    /// only metric region is a triplet).
    fn open_plain(seed: u64) -> EditorSession {
        EditorSession::open(valid_score(seed), Box::new(StubSolver)).expect("plain renders")
    }

    /// Clicks the centre of the first notehead (a pitch-backed glyph) and returns the
    /// resulting selection.
    fn click_a_notehead(session: &mut EditorSession) -> Selection {
        let click = session
            .hit_test()
            .regions
            .iter()
            .filter(|r| r.primitive.is_glyph() && matches!(r.source, TypedObjectId::Pitch(_)))
            .find_map(|r| match r.shape {
                HitShape::Box(b) => Some(Point::new(
                    (b.left.0 + b.right.0) / 2.0,
                    (b.bottom.0 + b.top.0) / 2.0,
                )),
                HitShape::Segment { .. } | HitShape::Curve { .. } => None,
            })
            .expect("the rich fixture renders a notehead");
        session.click(click).expect("the click selects a glyph")
    }

    /// Selects the pitch `pid` by finding its notehead in the hit map.
    fn select_pitch(session: &mut EditorSession, pid: PitchId) -> Selection {
        let lo = session
            .hit_test()
            .regions
            .iter()
            .find(|r| r.source == TypedObjectId::Pitch(pid))
            .map(|r| r.layout_object)
            .expect("the notehead is in the hit map");
        session.select(lo).expect("selects the pitch")
    }

    /// Clicking a slur curve selects the slur (schema-major-2 E2). A slur draws
    /// a cubic-bézier `Curve` primitive whose hit region flows through
    /// `click()` generically — no slur-specific arm — so the selection resolves
    /// to `TypedObjectId::Slur`. A click on the *arc* (its `t = 0.5` apex, not
    /// its chord) hits it, proving the flattened-capsule hit shape.
    #[test]
    fn clicking_a_slur_curve_selects_the_slur() {
        use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle};

        let mut score = valid_score_rich(0x5EED);
        // Two events of region A's first voice, on its one staff.
        let events: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0]
            .events
            .clone();
        let slur_id: SlurId = score.identity.mint();
        score.cross_cutting.slurs.push(Slur {
            id: slur_id,
            start_event: events[0],
            end_event: events[2],
            kind: SlurKind::Legato,
            curvature_override: None,
            style: SpanStyle::default(),
        });
        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");

        // The slur's curve region, and the arc's apex (cubic at t = 0.5).
        let apex = session
            .hit_test()
            .regions
            .iter()
            .find_map(|r| match r.shape {
                HitShape::Curve { p0, p1, p2, p3, .. }
                    if r.source == TypedObjectId::Slur(slur_id) =>
                {
                    let mid = |a: f32, b: f32, c: f32, d: f32| (a + 3.0 * b + 3.0 * c + d) / 8.0;
                    Some(Point::new(
                        mid(p0.x.0, p1.x.0, p2.x.0, p3.x.0),
                        mid(p0.y.0, p1.y.0, p2.y.0, p3.y.0),
                    ))
                }
                _ => None,
            })
            .expect("the slur draws a curve region");

        let selection = session.click(apex).expect("the click selects the slur");
        assert_eq!(selection.source, TypedObjectId::Slur(slur_id));
        // A slur is not an editable target: an edit op cleanly refuses it
        // rather than mishandling the non-pitch selection.
        assert!(session.alter_selection(1).is_err());
    }

    /// A pitch in the last event of the first voice that has events — its slot has
    /// room after it (nothing follows in that voice), so an insert-after applies.
    fn last_event_pitch(session: &EditorSession) -> PitchId {
        let last_eid = session
            .score()
            .voices()
            .filter_map(|(_, _, v)| v.events.last().copied())
            .next()
            .expect("a voice with events");
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        session
            .score()
            .events
            .get(last_eid)
            .unwrap()
            .collect_identified_pitches(&mut buf);
        buf.first().expect("the last event has a pitch").id
    }

    #[test]
    fn open_renders_and_starts_unselected() {
        let session = open_rich(0x5EED);
        assert!(!session.render().primitives.is_empty(), "the score renders");
        assert!(!session.hit_test().regions.is_empty(), "with hit regions");
        assert!(
            !session.resolved().glyphs.is_empty(),
            "the resolved layout a renderer consumes is exposed"
        );
        assert_eq!(session.selection(), None, "nothing is selected at open");
    }

    #[test]
    fn a_click_selects_the_topmost_hit() {
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        assert!(matches!(selection.source, TypedObjectId::Pitch(_)));
        assert_eq!(session.selection(), Some(selection));
        // Re-selecting that layout object by id resolves to the same thing.
        assert_eq!(session.select(selection.layout_object), Some(selection));
        // A click on empty space clears the selection.
        session.click(Point::new(-1.0e6, -1.0e6));
        assert_eq!(session.selection(), None);
    }

    #[test]
    fn staff_pitch_at_reads_the_clicked_height() {
        let session = open_rich(0x5EED);
        // The lowest staff-line stroke is the bottom staff's step origin.
        let origin = session
            .resolved()
            .strokes
            .iter()
            .filter(|s| matches!(s.provenance.source, TypedObjectId::Staff(_)))
            .map(|s| s.from.y.0)
            .fold(f32::INFINITY, f32::min);
        assert!(origin.is_finite(), "the score renders staff lines");

        let diatonic = |p: StaffPitch| p.octave as i32 * 7 + p.nominal as i32;
        let at = |dy: f32| session.staff_pitch_at(Point::new(5.0, origin + dy));

        let bottom = at(0.0).expect("a staff under the click");
        // Two staff spaces up is four diatonic steps (a fifth) higher, on the same
        // staff — so y maps to staff steps at two steps per space.
        let up_a_fifth = at(2.0).expect("still over the staff");
        assert_eq!(up_a_fifth.staff_instance, bottom.staff_instance);
        assert_eq!(diatonic(up_a_fifth) - diatonic(bottom), 4);
        // A half-space below the bottom line is one diatonic step down.
        let below = at(-0.5).expect("just below the staff still resolves");
        assert_eq!(diatonic(bottom) - diatonic(below), 1);
    }

    #[test]
    fn staff_pitch_at_picks_the_staff_under_the_x() {
        let session = open_rich(0x5EED);
        // Each staff manifestation's bottom line: its step-origin y and its x span.
        let mut bands: Vec<(StaffInstanceId, f32, f32, f32)> = session
            .score()
            .staff_instances()
            .filter_map(|(region, si)| {
                let m = manifestation_layout_id(&TypedObjectId::Staff(si.staff), region);
                session
                    .resolved()
                    .strokes
                    .iter()
                    .find(|s| s.provenance.stable_id == m)
                    .map(|s| {
                        (
                            si.id,
                            s.from.x.0.min(s.to.x.0),
                            s.from.x.0.max(s.to.x.0),
                            s.from.y.0,
                        )
                    })
            })
            .collect();
        bands.sort_by(|a, b| a.1.total_cmp(&b.1));
        // The fixture tiles its staves left-to-right sharing one y band — so height
        // alone cannot tell them apart; only x identifies the clicked staff.
        assert!(bands.len() >= 2, "the fixture manifests multiple staves");
        let origin = bands[0].3;
        assert!(
            bands.iter().all(|b| (b.3 - origin).abs() < 1e-3),
            "the staves share a y band, so the click is ambiguous by height alone"
        );
        let mut resolved = std::collections::BTreeSet::new();
        for &(expected, lo, hi, _) in &bands {
            // Click the centre of this staff's x span, at the shared band height.
            let pitch = session
                .staff_pitch_at(Point::new((lo + hi) / 2.0, origin + 2.0))
                .expect("a staff under the click");
            assert_eq!(
                pitch.staff_instance, expected,
                "the click resolves to the staff occupying that x, not a height-only pick"
            );
            resolved.insert(pitch.staff_instance);
        }
        assert_eq!(
            resolved.len(),
            bands.len(),
            "distinct x positions resolved to distinct staves (height alone could not)"
        );
    }

    #[test]
    fn staff_pitch_at_rejects_non_finite_clicks() {
        let session = open_rich(0x5EED);
        // A finite click resolves, so the fixture has a staff to hit.
        assert!(session.staff_pitch_at(Point::new(5.0, 0.0)).is_some());
        // A malformed view transform can hand a NaN/inf coordinate; it must yield no
        // pitch, not a bogus one (NaN would slip through `dist_to_band` as distance 0
        // and saturate the `round() as i32` step).
        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            assert_eq!(session.staff_pitch_at(Point::new(bad, 0.0)), None);
            assert_eq!(session.staff_pitch_at(Point::new(5.0, bad)), None);
        }
    }

    #[test]
    fn staff_start_clefs_resolve_by_time_not_vector_order() {
        use epiphany_core::{MusicalPosition, RationalTime};
        use epiphany_layout_ir::{LayoutObject, PlacedClef};

        let at = |n, d| TimePoint::Musical(MusicalPosition(RationalTime::new(n, d).unwrap()));
        let mut logical = to_logical(&valid_score_rich(0x5EED));
        // Author one staff's clefs out of order: bass at time 1, treble at the origin.
        // The clef in force at the staff start is treble (the latest change at or
        // before time 0), even though bass is first in the vector.
        let mut patched = None;
        'outer: for region in &mut logical.regions {
            let TypedObjectId::Region(rid) = region.provenance.source else {
                continue;
            };
            for object in &mut region.objects {
                // Match on content, not variant: the staff-content object projects from
                // a `StaffInstance` source, so it is not the `Staff` layout variant.
                let LayoutContent::Staff(content) = object.content() else {
                    continue;
                };
                let Some(staff) = object.staff() else {
                    continue;
                };
                let mut content = content.clone();
                content.clefs = vec![
                    PlacedClef {
                        time: at(1, 1),
                        clef: Clef::bass(),
                    },
                    PlacedClef {
                        time: at(0, 1),
                        clef: Clef::treble(),
                    },
                ];
                *object = LayoutObject::from_projection_with_content(
                    object.provenance().clone(),
                    Some(staff),
                    LayoutContent::Staff(content),
                );
                patched = Some((rid, staff));
                break 'outer;
            }
        }
        let key = patched.expect("the fixture has a staff layout object");
        let clefs = staff_start_clefs(&logical);
        assert_eq!(
            clefs.get(&key).copied(),
            Some(Clef::treble()),
            "the start clef resolves by time (treble@0), not vector order (bass first)"
        );
    }

    /// The first region whose events have `time_model`-matching positions, picked by
    /// the `metric` flag (a `Musical` vs `WallClock` onset).
    fn a_region_with(session: &EditorSession, metric: bool) -> RegionId {
        session
            .score()
            .voices()
            .find_map(|(rid, _, v)| {
                v.events.iter().find_map(|eid| {
                    let ev = session.score().events.get(*eid)?;
                    (matches!(ev.position(), EventPosition::Musical(_)) == metric).then_some(rid)
                })
            })
            .expect("a region with the requested time model")
    }

    /// `region`'s pitched metric events as `(onset, first pitch id)`, in onset order.
    fn region_pitched_events(
        session: &EditorSession,
        region: RegionId,
    ) -> Vec<(MusicalPosition, PitchId)> {
        let mut out = Vec::new();
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        for (rid, _, v) in session.score().voices() {
            if rid != region {
                continue;
            }
            for eid in &v.events {
                let Some(ev) = session.score().events.get(*eid) else {
                    continue;
                };
                let EventPosition::Musical(at) = ev.position() else {
                    continue;
                };
                buf.clear();
                ev.collect_identified_pitches(&mut buf);
                if let Some(ip) = buf.first() {
                    out.push((at.clone(), ip.id));
                }
            }
        }
        out.sort_by(|a, b| a.0.cmp(&b.0));
        out
    }

    /// `region`'s rendered bottom staff line as `(left_x, right_x, origin_y)`.
    ///
    /// **Flat-layout (stub) helper**: it finds the stroke carrying the staff's
    /// manifestation id, which in a cast-off layout is only the *first* system's
    /// segment. Every test here runs on the [`StubSolver`], which never splits a
    /// line, so the first segment is the whole line; multi-system geometry is
    /// exercised via [`install_two_system_geometry`] and, over the real engraver,
    /// by the testkit's `multisystem_click` integration test.
    fn region_staff_line(session: &EditorSession, region: RegionId) -> (f32, f32, f32) {
        let (_, si) = session
            .score()
            .staff_instances()
            .find(|(r, _)| *r == region)
            .expect("the region has a staff instance");
        let m = manifestation_layout_id(&TypedObjectId::Staff(si.staff), region);
        let line = session
            .resolved()
            .strokes
            .iter()
            .find(|s| s.provenance.stable_id == m)
            .expect("the staff renders its bottom line");
        (
            line.from.x.0.min(line.to.x.0),
            line.from.x.0.max(line.to.x.0),
            line.from.y.0,
        )
    }

    /// A world point on `region`'s staff: the midpoint of its bottom line, one staff
    /// space up (inside the staff, so the click resolves to this region).
    fn point_on_region_staff(session: &EditorSession, region: RegionId) -> Point {
        let (left, right, origin_y) = region_staff_line(session, region);
        Point::new((left + right) / 2.0, origin_y + 1.0)
    }

    #[test]
    fn position_at_snaps_a_click_to_the_beat_grid() {
        let session = open_rich(0x5EED);
        let region = a_region_with(&session, true);
        let anchors = session.position_anchors(region, None);
        assert!(
            anchors.len() >= 2,
            "the metric region renders multiple notes"
        );
        let (_, right, origin_y) = region_staff_line(&session, region);
        let y = origin_y + 1.0;

        // Grid = the first inter-onset gap, so every rendered onset is a grid multiple.
        let step = MusicalDuration(anchors[1].0 .0.sub(&anchors[0].0 .0));
        assert!(step.is_positive());
        let grid = GridResolution { step };

        // Clicking each notehead snaps to that note's onset.
        for (onset, x) in &anchors {
            let gp = session
                .position_at(Point::new(*x, y), &grid)
                .expect("a metric region under the click");
            assert_eq!(gp.region, region);
            assert_eq!(&gp.position, onset, "the click snaps to the note's onset");
        }

        // Past the last note but still over this staff (between the last notehead and
        // the staff's right edge — the empty space a make-room insert targets), the
        // inverse extrapolates to a later grid slot, strictly after the last onset.
        let (last_onset, last_x) = anchors.last().cloned().unwrap();
        let reach = (last_x + right) / 2.0;
        assert!(reach > last_x, "the staff extends past the last note");
        let far = session
            .position_at(Point::new(reach, y), &grid)
            .expect("empty space past the notes still resolves");
        assert!(
            far.position > last_onset,
            "a click past the last note lands later on the grid"
        );

        // Left of the first note clamps to the region origin (no negative musical time).
        let before = session
            .position_at(Point::new(anchors[0].1 - 1000.0, y), &grid)
            .expect("left of the first note still resolves");
        assert_eq!(before.position, MusicalPosition::origin());
    }

    #[test]
    fn position_at_anchors_on_the_notehead_not_the_accidental() {
        // An accidental shares its note's `Pitch` source but is drawn left of the
        // notehead. Sharpen a note so it grows an accidental, then verify a click on
        // its notehead still snaps to the note's onset — the accidental's leftward x
        // must not become the time anchor.
        let base = open_rich(0x5EED);
        let region = a_region_with(&base, true);
        let events = region_pitched_events(&base, region);
        assert!(
            events.len() >= 2,
            "the metric region renders multiple notes"
        );
        // Grid = the first inter-onset gap, so every onset is a grid multiple.
        let grid = GridResolution {
            step: MusicalDuration(events[1].0 .0.sub(&events[0].0 .0)),
        };
        let (_, _, origin_y) = region_staff_line(&base, region);

        // Use the first note past the origin whose sharpen renders an accidental
        // (a semitone up is a natural for some spellings, e.g. E→F — skip those).
        let mut tested = false;
        for (onset, pid) in events.iter().skip(1) {
            let mut session = open_rich(0x5EED);
            select_pitch(&mut session, *pid);
            if session.alter_selection(1).is_err() {
                continue;
            }
            let glyph_x = |synthesized: bool| {
                session
                    .resolved()
                    .glyphs
                    .iter()
                    .filter(|g| {
                        g.provenance.source == TypedObjectId::Pitch(*pid)
                            && g.provenance.synthesis.is_some() == synthesized
                    })
                    .map(|g| g.position.x.0)
                    .next()
            };
            let Some(notehead_x) = glyph_x(false) else {
                continue;
            };
            let Some(accidental_x) = glyph_x(true) else {
                continue; // the sharpen produced a natural; not the case we want
            };
            assert!(
                accidental_x < notehead_x,
                "the accidental sits left of the notehead"
            );
            // The onset's anchor must be the notehead x, not the (leftmost) accidental
            // — the exact check, independent of how coarse the grid is.
            let anchor_x = session
                .position_anchors(region, None)
                .into_iter()
                .find(|(o, _)| o == onset)
                .map(|(_, x)| x)
                .expect("the sharped note has a rendered anchor");
            assert_eq!(
                anchor_x, notehead_x,
                "the onset anchors on the notehead, not the accidental left of it"
            );
            // And end-to-end: clicking the notehead snaps to the note's onset.
            let gp = session
                .position_at(Point::new(notehead_x, origin_y + 1.0), &grid)
                .expect("a metric region under the click");
            assert_eq!(gp.position, *onset, "the click snaps to the note's onset");
            tested = true;
            break;
        }
        assert!(
            tested,
            "a sharpened note rendered an accidental to test the anchor against"
        );
    }

    #[test]
    fn position_at_rejects_a_non_metric_region() {
        let session = open_rich(0x5EED);
        // The fixture's middle region is proportional (wall-clock events) — it has no
        // musical onset to land on, so a click over it yields no grid position.
        let region = a_region_with(&session, false);
        assert!(!session.region_is_metric(region));
        let grid = GridResolution {
            step: MusicalDuration(RationalTime::new(1, 4).unwrap()),
        };
        assert_eq!(
            session.position_at(point_on_region_staff(&session, region), &grid),
            None
        );
    }

    #[test]
    fn position_at_rejects_a_non_positive_grid() {
        let session = open_rich(0x5EED);
        let region = a_region_with(&session, true);
        let at = point_on_region_staff(&session, region);
        // A positive grid resolves; a zero/negative one cannot snap, so it is refused.
        let ok = GridResolution {
            step: MusicalDuration(RationalTime::new(1, 12).unwrap()),
        };
        assert!(session.position_at(at, &ok).is_some());
        let zero = GridResolution {
            step: MusicalDuration(RationalTime::zero()),
        };
        assert_eq!(session.position_at(at, &zero), None);
    }

    /// Where [`install_two_system_geometry`] puts each system's staff bottom line
    /// (the step origin), in world y: system 1 on top, system 2 below it.
    const SYS1_ORIGIN_Y: f32 = 0.0;
    const SYS2_ORIGIN_Y: f32 = -20.0;

    /// Overwrites `session`'s resolved geometry with a hand-built **two-system
    /// cast-off layout** over its single metric region — the shape the real
    /// engraver produces and the stub never does. The first half of the region's
    /// onsets renders on system 1, the rest on system 2; both systems start at the
    /// same left margin (x restarts, so the region-wide anchor list is
    /// x-non-monotonic in time) and sit in disjoint y bands. Each system carries a
    /// staff record whose provenance is its own bottom-line stroke — system 1 the
    /// staff's manifestation provenance, system 2 a synthesized continuation —
    /// exactly as the engraver's casting pass writes them. Only the resolved
    /// geometry is replaced (render/hit-test stay the stub's): these tests
    /// exercise the resolved-geometry queries alone.
    ///
    /// Returns the region, each event as `(onset, anchor x, system index)` in
    /// onset order, and the two system bounding boxes.
    fn install_two_system_geometry(
        session: &mut EditorSession,
    ) -> (RegionId, Vec<(MusicalPosition, f32, usize)>, Rect, Rect) {
        use epiphany_layout_ir::{
            BoundingBox, GlyphReference, GlyphStyle, Margins, Provenance, ResolvedGlyph,
            ResolvedPage, ResolvedStaff, Size2D, StaffSpace, Stroke, SynthesisInstanceKey,
            SynthesisKind,
        };

        let region = a_region_with(session, true);
        let staff = session
            .score()
            .staff_instances()
            .find(|(r, _)| *r == region)
            .map(|(_, si)| si.staff)
            .expect("the metric region has a staff instance");
        let events = region_pitched_events(session, region);
        assert!(
            events.len() >= 4,
            "four onsets give each system two anchors to fix a scale"
        );
        assert!(
            events.windows(2).all(|w| w[0].0 < w[1].0),
            "onsets are strictly ascending (distinct)"
        );
        let half = events.len() / 2;

        let staff_source = TypedObjectId::Staff(staff);
        // System 1 keeps the staff's manifestation provenance; system 2's line is
        // an engraver-synthesized continuation with its own stable id — the split
        // casting-off performs on a system-spanning stroke.
        let line_provenance = [
            Provenance::manifested(staff_source, region, vec![]),
            Provenance::synthesized(
                staff_source,
                SynthesisKind::EngravedBreak,
                SynthesisInstanceKey(1),
                vec![],
            ),
        ];
        let origins = [SYS1_ORIGIN_Y, SYS2_ORIGIN_Y];

        let systems: Vec<ResolvedSystem> = origins
            .iter()
            .zip(&line_provenance)
            .enumerate()
            .map(|(s, (&origin, provenance))| ResolvedSystem {
                provenance: if s == 0 {
                    Provenance::projected(TypedObjectId::Region(region), vec![])
                } else {
                    Provenance::synthesized(
                        TypedObjectId::Region(region),
                        SynthesisKind::EngravedBreak,
                        SynthesisInstanceKey(2),
                        vec![],
                    )
                },
                bounding_box: Rect {
                    origin: Point::new(0.0, origin - 2.0),
                    size: Size2D {
                        width: StaffSpace(90.0),
                        height: StaffSpace(STAFF_SPAN + 4.0),
                    },
                },
                staves: vec![ResolvedStaff {
                    provenance: provenance.clone(),
                    staff,
                    bounding_box: Rect {
                        origin: Point::new(0.0, origin - 0.05),
                        size: Size2D {
                            width: StaffSpace(88.0),
                            height: StaffSpace(STAFF_SPAN + 0.1),
                        },
                    },
                }],
                measures: Vec::new(),
                // Mechanical field-add only (CONTRACT_EDITOR_T4PRE_IR.md W1):
                // this hit-test fixture tests neither ownership nor consumes
                // it — editor-core adopts the accessor at T4, not here.
                primitives: Default::default(),
            })
            .collect();
        let strokes: Vec<Stroke> = origins
            .iter()
            .zip(&line_provenance)
            .map(|(&y, provenance)| Stroke {
                provenance: provenance.clone(),
                vertical_band: epiphany_layout_ir::VerticalBandId(0),
                from: Point::new(0.0, y),
                to: Point::new(88.0, y),
                thickness: StaffSpace(0.1),
                layer: 0,
                style: GlyphStyle::default(),
            })
            .collect();

        let mut placed: Vec<(MusicalPosition, f32, usize)> = Vec::new();
        let glyphs: Vec<ResolvedGlyph> = events
            .iter()
            .enumerate()
            .map(|(i, (onset, pid))| {
                let system = usize::from(i >= half);
                let local = if system == 0 { i } else { i - half };
                // 20 staff spaces per quarter, both systems restarting at x = 10.
                let x = 10.0 + 20.0 * local as f32;
                placed.push((onset.clone(), x, system));
                ResolvedGlyph {
                    provenance: Provenance::manifested(TypedObjectId::Pitch(*pid), region, vec![]),
                    glyph: GlyphReference::borrowed("noteheadBlack"),
                    position: Point::new(x, origins[system] + 1.0),
                    transform: None,
                    bounding_box: BoundingBox::new(0.0, -0.5, 1.2, 0.5),
                    style: GlyphStyle::default(),
                    layer: 0,
                }
            })
            .collect();

        let (sys1_box, sys2_box) = (systems[0].bounding_box, systems[1].bounding_box);
        session.resolved.pages = vec![ResolvedPage {
            provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
            number: 1,
            size: Size2D::default(),
            margins: Margins::default(),
            systems,
            free_objects: Vec::new(),
        }];
        session.resolved.glyphs = glyphs;
        session.resolved.strokes = strokes;
        (region, placed, sys1_box, sys2_box)
    }

    #[test]
    fn containing_system_requires_real_cast_geometry() {
        // The stub's page tree carries only degenerate (zero-size) system boxes:
        // no system may capture a click, and the flat single-system path stays in
        // charge — which is what keeps every pre-casting behavior unchanged.
        let session = open_plain(1);
        assert!(
            !session.resolved().pages.is_empty(),
            "the stub emits a page tree"
        );
        assert!(session.containing_system(Point::new(1.0, 0.0)).is_none());
        let region = a_region_with(&session, true);
        let at = point_on_region_staff(&session, region);
        assert!(
            session.staff_pitch_at(at).is_some(),
            "the flat path still resolves the click"
        );
    }

    #[test]
    fn staff_pitch_at_reads_the_clicked_system_origin() {
        let mut session = open_plain(1);
        let (_region, _placed, sys1, sys2) = install_two_system_geometry(&mut session);

        // Same staff-relative height, one click per system: the pitch must match —
        // system 2's step origin is its own bottom line, not system 1's. (The
        // regression: only the first line segment keeps the manifestation stable
        // id, so the flat path read every system-2 click against system 1's
        // origin, ~20 staff spaces off.)
        let p1 = session
            .staff_pitch_at(Point::new(30.0, SYS1_ORIGIN_Y + 1.0))
            .expect("a staff under the system-1 click");
        let p2 = session
            .staff_pitch_at(Point::new(30.0, SYS2_ORIGIN_Y + 1.0))
            .expect("a staff under the system-2 click");
        assert_eq!(
            p1.staff_instance, p2.staff_instance,
            "one staff, two systems"
        );
        assert_eq!(
            (p2.nominal, p2.octave),
            (p1.nominal, p1.octave),
            "the same staff-relative height names the same pitch in either system"
        );

        // The containing system is keyed on the click's y — full containment first…
        let in_sys2 = session
            .containing_system(Point::new(30.0, SYS2_ORIGIN_Y + 1.0))
            .expect("system 2 contains the point");
        assert_eq!(in_sys2.bounding_box, sys2);
        // …and a click in the inter-system gutter resolves to the nearest system
        // by vertical distance (mirroring the nearest-staff tolerance), never to
        // nothing.
        let just_under_sys1 = Point::new(30.0, rect_y_band(&sys1).0 - 1.0);
        assert_eq!(
            session
                .containing_system(just_under_sys1)
                .expect("the gutter still resolves")
                .bounding_box,
            sys1
        );
        let just_over_sys2 = Point::new(30.0, rect_y_band(&sys2).1 + 1.0);
        assert_eq!(
            session
                .containing_system(just_over_sys2)
                .expect("the gutter still resolves")
                .bounding_box,
            sys2
        );
    }

    #[test]
    fn position_at_inverts_within_the_clicked_system() {
        let mut session = open_plain(1);
        let (region, placed, _sys1, sys2) = install_two_system_geometry(&mut session);
        let half = placed.iter().filter(|(_, _, s)| *s == 0).count();
        // The fixture's onsets are consecutive quarters, so a quarter grid puts
        // every rendered onset on the grid.
        let quarter = grid(1, 4);
        let step = MusicalDuration(RationalTime::new(1, 4).unwrap());

        // Every anchor click snaps to its own onset — in both systems.
        for (onset, x, system) in &placed {
            let y = if *system == 0 {
                SYS1_ORIGIN_Y
            } else {
                SYS2_ORIGIN_Y
            } + 1.0;
            let gp = session
                .position_at(Point::new(*x, y), &quarter)
                .expect("a metric position under the click");
            assert_eq!(
                &gp.position, onset,
                "the click snaps to the clicked system's onset"
            );
        }
        // The regression pinned directly: system 2's first anchor shares its x
        // with system 1's first anchor but is a *later* time.
        let (first_sys2_onset, x0, _) = placed[half].clone();
        let gp = session
            .position_at(Point::new(x0, SYS2_ORIGIN_Y + 1.0), &quarter)
            .expect("a metric position under the click");
        assert_eq!(gp.position, first_sys2_onset);
        assert!(
            gp.position > placed[0].0,
            "a system-2 click is not a system-1 time"
        );

        // Anchor filtering: within system 2's box the run is monotonic in x and
        // carries exactly the second half of the onsets; the unfiltered
        // region-wide list is x-non-monotonic (the hazard the filter removes).
        let filtered = session.position_anchors(region, Some(&sys2));
        assert_eq!(filtered.len(), placed.len() - half);
        assert!(filtered.windows(2).all(|w| w[0].1 < w[1].1));
        assert_eq!(filtered[0].0, first_sys2_onset);
        let flat = session.position_anchors(region, None);
        assert_eq!(flat.len(), placed.len());
        assert!(
            !flat.windows(2).all(|w| w[0].1 < w[1].1),
            "the region-wide anchor list is x-non-monotonic across systems"
        );

        // End extrapolation stays within the clicked system: one anchor gap right
        // of a system's last note is that system's next grid slot. For system 1
        // that names the time system 2 renders first — the result is a musical
        // position, not a system-local one.
        let (last_onset, last_x, _) = placed.last().cloned().unwrap();
        let past = session
            .position_at(Point::new(last_x + 20.0, SYS2_ORIGIN_Y + 1.0), &quarter)
            .expect("empty space past the last note still resolves");
        assert_eq!(past.position, last_onset + step.clone());
        let (sys1_last_onset, sys1_last_x, _) = placed[half - 1].clone();
        let hang = session
            .position_at(
                Point::new(sys1_last_x + 20.0, SYS1_ORIGIN_Y + 1.0),
                &quarter,
            )
            .expect("system 1's trailing space still resolves");
        assert_eq!(hang.position, sys1_last_onset + step);
    }

    #[test]
    fn position_at_rejects_non_finite_clicks() {
        let session = open_rich(0x5EED);
        let grid = GridResolution {
            step: MusicalDuration(RationalTime::new(1, 12).unwrap()),
        };
        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            assert_eq!(session.position_at(Point::new(bad, 1.0), &grid), None);
            assert_eq!(session.position_at(Point::new(5.0, bad), &grid), None);
        }
    }

    /// A time signature with `display`, a measure of `measure`, and one beat group
    /// spanning it (so the beat-sum invariant holds).
    fn time_sig(
        display: epiphany_core::TimeSignatureDisplay,
        measure: MusicalDuration,
    ) -> TimeSignature {
        use epiphany_core::{BeatGroup, TimeSignatureId};
        TimeSignature::new(
            TimeSignatureId::new(ReplicaId(9), 0),
            display,
            measure.clone(),
            vec![BeatGroup {
                duration: measure,
                subdivision: None,
                accent: 0,
            }],
        )
        .expect("a single-beat-group time signature is well-formed")
    }

    #[test]
    fn time_signature_beat_is_one_over_the_denominator() {
        use epiphany_core::{PowerOfTwo, TimeSignatureDisplay};
        let whole = MusicalDuration(RationalTime::new(1, 1).unwrap());
        // 4/4 → quarter; 2/2 → half.
        let four_four = time_sig(
            TimeSignatureDisplay::Standard {
                numerator: 4,
                denominator: PowerOfTwo::new(4).unwrap(),
            },
            whole.clone(),
        );
        assert_eq!(time_signature_beat(&four_four), Some(dur(1, 4)));
        let cut = time_sig(
            TimeSignatureDisplay::Standard {
                numerator: 2,
                denominator: PowerOfTwo::new(2).unwrap(),
            },
            whole.clone(),
        );
        assert_eq!(time_signature_beat(&cut), Some(dur(1, 2)));
        // A meter with no single denominator has no derivable beat.
        let mixed = time_sig(
            TimeSignatureDisplay::MixedDenominators { components: vec![] },
            whole,
        );
        assert_eq!(time_signature_beat(&mixed), None);
    }

    #[test]
    fn default_grid_at_uses_the_governing_meter() {
        use epiphany_core::{PowerOfTwo, TimeSignatureDisplay};
        // Declare a 6/8 (its beat is an eighth). With no measure referencing one, the
        // score's first time signature governs — the same fallback `resolve_measure_units`
        // uses.
        let mut score = valid_score(0x5EED);
        score.time_signatures.push(time_sig(
            TimeSignatureDisplay::Standard {
                numerator: 6,
                denominator: PowerOfTwo::new(8).unwrap(),
            },
            dur(6, 8),
        ));
        let session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let region = a_clean_metric_region(&session);
        let at = point_on_region_staff(&session, region);
        assert_eq!(
            session.default_grid_at(at),
            Some(GridResolution { step: dur(1, 8) }),
            "a 6/8 meter gives an eighth-note grid"
        );
    }

    #[test]
    fn default_grid_at_defaults_to_a_quarter_without_a_meter() {
        // The plain fixture declares no time signatures, so the default is a quarter.
        let session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let at = point_on_region_staff(&session, region);
        assert_eq!(session.default_grid_at(at), Some(GridResolution::quarter()));
        // A non-finite click resolves to no staff, so there is no grid.
        assert_eq!(session.default_grid_at(Point::new(f32::NAN, 0.0)), None);
    }

    fn grid(numerator: i64, denominator: i64) -> GridResolution {
        GridResolution {
            step: MusicalDuration(RationalTime::new(numerator, denominator).unwrap()),
        }
    }

    /// A metric region (its time model is metric) none of whose events are tuplet
    /// members — the clean target for the make-room tests.
    fn a_clean_metric_region(session: &EditorSession) -> RegionId {
        session
            .score()
            .canvas
            .regions
            .iter()
            .filter(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
            .map(|r| r.id)
            .find(|rid| {
                session
                    .score()
                    .voices()
                    .filter(|(r, _, _)| r == rid)
                    .flat_map(|(_, _, v)| v.events.clone())
                    .all(|eid| {
                        !session
                            .score()
                            .cross_cutting
                            .tuplets
                            .iter()
                            .any(|t| t.members.contains(&eid))
                    })
            })
            .expect("a tuplet-free metric region")
    }

    fn primary_voice(session: &EditorSession, region: RegionId) -> VoiceId {
        let (_, si) = session
            .score()
            .staff_instances()
            .find(|(r, _)| *r == region)
            .expect("the region has a staff instance");
        si.voices
            .iter()
            .find(|v| v.is_primary)
            .or_else(|| si.voices.first())
            .expect("the staff has a voice")
            .id
    }

    fn voice_events(
        session: &EditorSession,
        voice: VoiceId,
    ) -> Vec<(EventId, MusicalPosition, MusicalDuration)> {
        let mut out = Vec::new();
        for ev in session.score().events.iter() {
            if ev.voice() != voice {
                continue;
            }
            if let (EventPosition::Musical(p), EventDuration::Musical(d)) =
                (ev.position(), ev.duration())
            {
                out.push((ev.id(), p.clone(), d.clone()));
            }
        }
        out.sort_by(|a, b| a.1.cmp(&b.1));
        out
    }

    /// A click whose horizontal inverse snaps to `position` in `region` — derived from
    /// the region's rendered anchors, so it is the inverse of `position_at` (the stub
    /// solver spaces a metric region's onsets linearly, so a global fit suffices).
    fn click_for_position(
        session: &EditorSession,
        region: RegionId,
        position: &MusicalPosition,
        y: f32,
    ) -> Point {
        let anchors = session.position_anchors(region, None);
        let (p0, x0) = (anchors[0].0 .0.to_f64(), anchors[0].1 as f64);
        let last = anchors.last().unwrap();
        let (p1, x1) = (last.0 .0.to_f64(), last.1 as f64);
        let t = (position.0.to_f64() - p0) / (p1 - p0);
        Point::new((x0 + t * (x1 - x0)) as f32, y)
    }

    #[test]
    fn insert_note_at_fills_empty_space() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let (_, _, origin_y) = region_staff_line(&session, region);

        // The onset just after the last note — empty space, so a bare insert.
        let (_, last_pos, last_dur) = before.last().unwrap().clone();
        let target = MusicalPosition(last_pos.0.add(&last_dur.0));
        let at = click_for_position(&session, region, &target, origin_y + 1.0);
        let outcome = session
            .insert_note_at(at, &grid(1, 4))
            .expect("insert into empty space");
        assert!(outcome.graph_changed);

        let after = voice_events(&session, voice);
        assert_eq!(
            after.len(),
            before.len() + 1,
            "one note added, none removed"
        );
        assert!(
            after
                .iter()
                .any(|(_, p, d)| *p == target && d.0 == RationalTime::new(1, 4).unwrap()),
            "the new note sits at the snapped onset with the grid duration"
        );
    }

    #[test]
    fn insert_note_at_overwrites_a_covered_note() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let (_, _, origin_y) = region_staff_line(&session, region);

        // Click the last note with grid = its duration: the new note fully covers it.
        let (old_id, pos, dur) = before.last().unwrap().clone();
        let at = click_for_position(&session, region, &pos, origin_y + 1.0);
        session
            .insert_note_at(at, &GridResolution { step: dur })
            .expect("overwrite the covered note");

        let after = voice_events(&session, voice);
        assert_eq!(after.len(), before.len(), "delete + insert keeps the count");
        let covering = after
            .iter()
            .find(|(_, p, _)| *p == pos)
            .expect("a note at the covered onset");
        assert_ne!(covering.0, old_id, "the covered note was replaced");
        assert!(
            !after.iter().any(|(id, _, _)| *id == old_id),
            "the covered note's event is gone"
        );
    }

    #[test]
    fn insert_note_at_splits_an_enclosing_note() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let (_, _, origin_y) = region_staff_line(&session, region);

        // A grid a quarter of the first note's length, clicked one cell in, lands the
        // new note strictly inside the note (its second of four cells) — a split.
        let (first_id, first_pos, first_dur) = before.first().unwrap().clone();
        let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
        let step = GridResolution {
            step: MusicalDuration(cell.clone()),
        };
        let target = MusicalPosition(first_pos.0.add(&cell));
        let at = click_for_position(&session, region, &target, origin_y + 1.0);
        session
            .insert_note_at(at, &step)
            .expect("split the enclosing note");

        let after = voice_events(&session, voice);
        assert_eq!(
            after.len(),
            before.len() + 2,
            "the split adds the new note and a tail"
        );
        let head = after
            .iter()
            .find(|(id, _, _)| *id == first_id)
            .expect("the original note survives as the head");
        assert_eq!(head.1, first_pos, "the head keeps the original onset");
        assert_eq!(head.2 .0, cell, "the head is trimmed to the click");
        assert!(
            after
                .iter()
                .any(|(id, p, d)| *id != first_id && *p == target && d.0 == cell),
            "the new note sits at the click"
        );
        let tail_start = MusicalPosition(target.0.add(&cell));
        assert!(
            after.iter().any(|(_, p, _)| *p == tail_start),
            "a tail picks up where the new note ends"
        );
    }

    #[test]
    fn insert_note_at_split_tail_keeps_the_event_shape() {
        // Give the first metric note an articulation, then split it: the tail must keep
        // the note's shape (it is a clone with fresh ids), not be rebuilt as a default
        // note that drops articulations/dynamics/stem/grace.
        let mut score = valid_score(0x5EED);
        let region = score
            .canvas
            .regions
            .iter()
            .find(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
            .unwrap()
            .id;
        let voice = score
            .voices()
            .find(|(r, _, _)| *r == region)
            .map(|(_, _, v)| v.id)
            .unwrap();
        let first_id = {
            let mut evs: Vec<(EventId, MusicalPosition)> = score
                .events
                .iter()
                .filter(|e| e.voice() == voice)
                .filter_map(|e| match e.position() {
                    EventPosition::Musical(p) => Some((e.id(), p.clone())),
                    _ => None,
                })
                .collect();
            evs.sort_by(|a, b| a.1.cmp(&b.1));
            evs[0].0
        };
        if let Some(Event::Pitched(pe)) = score.events.get_mut(first_id) {
            pe.articulations.push(epiphany_core::ArticulationMark);
        }

        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let (_, _, origin_y) = region_staff_line(&session, region);
        let (_, first_pos, first_dur) = voice_events(&session, voice)
            .into_iter()
            .find(|(id, _, _)| *id == first_id)
            .unwrap();
        let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
        let target = MusicalPosition(first_pos.0.add(&cell));
        let at = click_for_position(&session, region, &target, origin_y + 1.0);
        session
            .insert_note_at(
                at,
                &GridResolution {
                    step: MusicalDuration(cell.clone()),
                },
            )
            .expect("split the articulated note");

        let tail_pos = MusicalPosition(target.0.add(&cell));
        let tail = session
            .score()
            .events
            .iter()
            .find(|e| {
                e.voice() == voice
                    && matches!(e.position(), EventPosition::Musical(p) if *p == tail_pos)
            })
            .expect("a tail event at the original note's remainder");
        match tail {
            Event::Pitched(pe) => assert!(
                !pe.articulations.is_empty(),
                "the split tail kept the note's articulation"
            ),
            _ => panic!("the tail of a pitched note is pitched"),
        }
        assert_ne!(
            tail.id(),
            first_id,
            "the tail is a fresh event, not the original"
        );
    }

    #[test]
    fn insert_note_at_split_tail_carries_an_authored_spelling() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let (_, _, origin_y) = region_staff_line(&session, region);
        let (first_id, first_pos, first_dur) = before.first().unwrap().clone();

        // Pin the first note's pitch with an explicit user spelling — an authored
        // override the split tail must carry onto its fresh pitch id.
        let source_pitch = {
            let ev = session.score().events.get(first_id).unwrap();
            let mut buf: Vec<&IdentifiedPitch> = Vec::new();
            ev.collect_identified_pitches(&mut buf);
            buf.first().expect("the first note has a pitch").id
        };
        let pinned = PitchSpelling::cmn(CmnNominal::D, 4);
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: source_pitch,
                spelling: pinned.clone(),
            }))
            .expect("the respell applies");
        assert_eq!(
            authored_spelling(session.score(), source_pitch),
            Some(pinned.clone())
        );

        // Split the pinned note (grid a quarter of its length, clicked one cell in).
        let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
        let target = MusicalPosition(first_pos.0.add(&cell));
        let at = click_for_position(&session, region, &target, origin_y + 1.0);
        session
            .insert_note_at(
                at,
                &GridResolution {
                    step: MusicalDuration(cell.clone()),
                },
            )
            .expect("split the pinned note");

        // The tail is a fresh event whose pitch carries the authored spelling.
        let tail_pos = MusicalPosition(target.0.add(&cell));
        let tail = session
            .score()
            .events
            .iter()
            .find(|e| {
                e.voice() == voice
                    && matches!(e.position(), EventPosition::Musical(p) if *p == tail_pos)
            })
            .expect("a tail event");
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        tail.collect_identified_pitches(&mut buf);
        let tail_pitch = buf.first().expect("the tail has a pitch").id;
        assert_ne!(tail_pitch, source_pitch, "the tail's pitch id is fresh");
        assert_eq!(
            authored_spelling(session.score(), tail_pitch),
            Some(pinned),
            "the authored spelling carried onto the tail"
        );
    }

    #[test]
    fn insert_note_at_cascades_a_whole_tuplet() {
        let mut session = open_rich(0x5EED);
        // The rich fixture's first metric region is a 3:2 eighth-note triplet over
        // [0, 1/4), and its first member carries an in-tuplet decomposition. Tuplets are
        // atomic: a pencil overwrite touching any member removes the *whole* tuplet —
        // every member, the structure, and the now-orphaned decomposition — freeing the
        // span for the new note and leaving an invariant-valid graph.
        let region = a_region_with(&session, true);
        let voice = primary_voice(&session, region);
        let members: Vec<EventId> = voice_events(&session, voice)
            .into_iter()
            .map(|(id, _, _)| id)
            .collect();
        assert_eq!(members.len(), 3, "the region is a three-member triplet");
        assert!(
            !session.score().cross_cutting.tuplets.is_empty(),
            "the region starts as a tuplet"
        );

        // Click the first member's onset with a grid of its own (eighth-note) value, so
        // the new note overlaps just that one member — yet the whole triplet cascades.
        let (_, pos, dur) = voice_events(&session, voice).into_iter().next().unwrap();
        let (_, _, origin_y) = region_staff_line(&session, region);
        let at = click_for_position(&session, region, &pos, origin_y + 1.0);
        session
            .insert_note_at(at, &GridResolution { step: dur.clone() })
            .expect("the overwrite cascades the tuplet and inserts the note");

        let after = voice_events(&session, voice);
        assert!(
            members
                .iter()
                .all(|m| !after.iter().any(|(id, _, _)| id == m)),
            "every original triplet member is gone"
        );
        assert!(
            session.score().cross_cutting.tuplets.is_empty(),
            "the tuplet structure is removed"
        );
        let inserted = after
            .iter()
            .find(|(id, _, _)| !members.contains(id))
            .expect("the new note was inserted");
        assert_eq!(inserted.1, pos, "the new note sits at the clicked onset");
        assert_eq!(inserted.2, dur, "the new note has the grid duration");
        assert!(
            epiphany_core::check_invariants(session.score()).is_empty(),
            "the cascaded graph is invariant-valid"
        );
    }

    #[test]
    fn insert_note_at_over_a_nested_tuplet_is_refused() {
        use epiphany_core::{Tuplet, TupletRatio};
        // A nested tuplet's ratio arithmetic a flat cascade cannot restate, so make-room
        // refuses rather than corrupt it. Give the fixture's flat triplet a child tuplet
        // so it is no longer flat, then try to overwrite one of its members.
        let mut score = valid_score_rich(0x5EED);
        let triplet_id = score.cross_cutting.tuplets[0].id;
        let replica = score.identity.replica_id;
        score.cross_cutting.tuplets.push(Tuplet {
            id: TupletId::new(replica, 7_000_002),
            ratio: TupletRatio::new(3, 2).unwrap(),
            members: vec![],
            parent: Some(triplet_id),
            required_total: MusicalDuration(RationalTime::new(1, 8).unwrap()),
        });
        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");

        let region = a_region_with(&session, true);
        let voice = primary_voice(&session, region);
        let (_, pos, dur) = voice_events(&session, voice).into_iter().next().unwrap();
        let (_, _, origin_y) = region_staff_line(&session, region);
        let at = click_for_position(&session, region, &pos, origin_y + 1.0);
        assert_eq!(
            session.insert_note_at(at, &GridResolution { step: dur }),
            Err(EditorError::OverlapsTuplet)
        );
    }

    #[test]
    fn insert_note_at_on_a_non_metric_region_is_no_target() {
        let mut session = open_rich(0x5EED);
        // The proportional region has no musical onset to insert at.
        let region = a_region_with(&session, false);
        let at = point_on_region_staff(&session, region);
        assert_eq!(
            session.insert_note_at(at, &grid(1, 4)),
            Err(EditorError::NoInsertTarget)
        );
    }

    fn dur(numerator: i64, denominator: i64) -> MusicalDuration {
        MusicalDuration(RationalTime::new(numerator, denominator).unwrap())
    }

    // --- T3-W1: the note-entry caret (`spec/CONTRACT_EDITOR_T3_CARET.md` §W1). ---

    /// t1: the caret's advance arithmetic crosses a would-be barline with no
    /// special casing — positions are global rationals. Entering a quarter at
    /// 3/4 must land the caret at exactly whole-note 1 (`RationalTime` 1/1).
    /// The literal fixture has no declared `TimeSignature`/measures (this is
    /// deliberate, not an oversight — `DECISIONS.md`: the caret's positions are
    /// meter-agnostic, so the test needs no real barline structure to make the
    /// point that none is special-cased); it asserts the raw arithmetic
    /// directly against a hand-placed caret.
    #[test]
    fn caret_advances_across_a_would_be_barline() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        session.caret = Some(Caret {
            voice,
            position: MusicalPosition(RationalTime::new(3, 4).unwrap()),
            entry_duration: dur(1, 4),
        });
        session.enter_rest().expect("enters at the caret");
        let caret = session.caret().expect("the caret is still set");
        assert_eq!(
            caret.position,
            MusicalPosition(RationalTime::new(1, 1).unwrap()),
            "3/4 + 1/4 lands exactly on whole-note 1"
        );
    }

    /// t2 (table): the minimum octave-inference rows the contract pins,
    /// including the "fifth below vs. fifth above" case — which this proves is
    /// **not** actually equidistant in diatonic staff steps (down wins 3 steps
    /// to 4), matching `infer_octave`'s own doc that no real letter pair ever
    /// ties.
    #[test]
    fn octave_inference_table() {
        assert_eq!(
            infer_octave(Some((CmnNominal::C, 4)), CmnNominal::D),
            4,
            "ref C4 enter D -> D4 (up a step, distance 1, beats down distance 6)"
        );
        assert_eq!(
            infer_octave(Some((CmnNominal::C, 4)), CmnNominal::B),
            3,
            "ref C4 enter B -> B3 (down a step, distance 1, beats up distance 6)"
        );
        assert_eq!(
            infer_octave(Some((CmnNominal::G, 4)), CmnNominal::D),
            4,
            "ref G4 enter D -> D4: down 3 steps beats up 4 -- NOT a tie in staff \
             steps (only a tie in semitones, a P5 either way); the downward \
             tie-break plays no role here"
        );
        assert_eq!(
            infer_octave(None, CmnNominal::A),
            4,
            "no reference -> the octave-4 default"
        );
    }

    /// t2 (tie-break): `infer_octave`'s doc proves no (reference, nominal) pair
    /// over the seven CMN letters can produce equal `down_distance`/
    /// `up_distance` arguments (the candidate octaves are 7 apart, and 7 is
    /// odd), so the downward tie-break is unreachable through the octave
    /// table alone. This exercises the actual comparison directly, with a
    /// synthetic tied input, so the policy is genuinely tested rather than
    /// merely asserted in a doc comment.
    #[test]
    fn octave_tie_break_prefers_downward() {
        assert!(
            nearer_is_down(3, 3),
            "an equal-distance tie resolves downward"
        );
        assert!(nearer_is_down(2, 3), "strictly nearer down still wins");
        assert!(
            !nearer_is_down(4, 3),
            "strictly nearer up wins on its own merits"
        );
    }

    /// t2 (end-to-end): `reference_pitch_before` finds the fixture's own last
    /// note (whatever seed-dependent nominal/octave it happens to carry) and
    /// `enter_nominal` inserts at the octave `infer_octave` computes from it --
    /// proving the two pieces are wired together correctly, not just each
    /// tested in isolation.
    #[test]
    fn enter_nominal_infers_octave_from_the_nearest_preceding_note() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let events = voice_events(&session, voice);
        let (last_id, last_pos, last_dur) = events.last().unwrap().clone();

        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        session
            .score()
            .events
            .get(last_id)
            .unwrap()
            .collect_identified_pitches(&mut buf);
        let PitchSpacePosition::Cmn {
            nominal: ref_nominal,
            octave: ref_octave,
            ..
        } = buf.first().unwrap().pitch.scale_position.position
        else {
            panic!("the fixture's notes are CMN pitches");
        };

        let insert_pos = MusicalPosition(last_pos.0.add(&last_dur.0));
        session.caret = Some(Caret {
            voice,
            position: insert_pos.clone(),
            entry_duration: dur(1, 4),
        });
        let entered = CmnNominal::D;
        let expected_octave = infer_octave(Some((ref_nominal, ref_octave)), entered);
        session.enter_nominal(entered).expect("enters at the caret");

        let after = session
            .score()
            .events
            .iter()
            .find(|e| {
                e.voice() == voice
                    && matches!(e.position(), EventPosition::Musical(p) if *p == insert_pos)
            })
            .expect("the new note was inserted at the caret's pre-advance position");
        let mut pbuf: Vec<&IdentifiedPitch> = Vec::new();
        after.collect_identified_pitches(&mut pbuf);
        let PitchSpacePosition::Cmn {
            nominal: got_nominal,
            octave: got_octave,
            ..
        } = pbuf.first().unwrap().pitch.scale_position.position
        else {
            panic!("enter_nominal inserts a CMN pitch");
        };
        assert_eq!(got_nominal, entered);
        assert_eq!(got_octave, expected_octave);
    }

    /// t3: entering over an occupied beat via the caret reproduces
    /// `insert_note_at`'s own make-room overwrite **exactly** -- compared on
    /// twin sessions opened from the same seed (so their fresh ids mint
    /// identically), one driven by the pencil, one by the caret at the same
    /// voice/position/duration and the same pitch the pencil's click would
    /// have resolved. The two resulting scores are asserted byte-for-byte
    /// equal, the strongest form of "matches what `insert_note_at` produces".
    #[test]
    fn enter_pitch_reproduces_insert_note_at_s_overwrite() {
        let seed = 0x5EED;
        let mut session_a = open_plain(seed);
        let region = a_clean_metric_region(&session_a);
        let voice = primary_voice(&session_a, region);
        let before = voice_events(&session_a, voice);
        let (_, _, origin_y) = region_staff_line(&session_a, region);

        // Click the last note with grid = its own duration: a full-cover overwrite.
        let (_, pos, note_dur) = before.last().unwrap().clone();
        let at = click_for_position(&session_a, region, &pos, origin_y + 1.0);
        let grid = GridResolution {
            step: note_dur.clone(),
        };
        let pitch = session_a
            .staff_pitch_at(at)
            .expect("a staff under the click");
        session_a
            .insert_note_at(at, &grid)
            .expect("the pencil overwrites the covered note");

        let mut session_b = open_plain(seed);
        session_b.caret = Some(Caret {
            voice,
            position: pos,
            entry_duration: note_dur,
        });
        session_b
            .enter_pitch(cmn_pitch(pitch.nominal, pitch.octave))
            .expect("the caret overwrites the covered note");

        assert_eq!(
            session_a.score(),
            session_b.score(),
            "the caret's make-room overwrite reproduces the pencil's exactly"
        );
    }

    /// t4: the MIDI table (contract minimum: 21/60/61/69/108).
    #[test]
    fn midi_note_to_pitch_table() {
        let check = |note: u8, nominal: CmnNominal, alteration: i8, octave: i8| {
            let pitch = midi_note_to_pitch(note);
            match pitch.scale_position.position {
                PitchSpacePosition::Cmn {
                    nominal: n,
                    alteration: a,
                    octave: o,
                } => assert_eq!((n, a, o), (nominal, alteration, octave), "midi note {note}"),
                other => panic!("midi note {note}: expected a CMN pitch, got {other:?}"),
            }
        };
        check(21, CmnNominal::A, 0, 0); // A0
        check(60, CmnNominal::C, 0, 4); // C4
        check(61, CmnNominal::C, 1, 4); // C#4
        check(69, CmnNominal::A, 0, 4); // A4
        check(108, CmnNominal::C, 0, 8); // C8
    }

    /// t5: `x_at_position` round-trips with `position_at` — every anchor onset
    /// (exact, already on the grid), and one point three grid steps past the
    /// last note (extrapolated, still on an exact grid multiple so there is no
    /// snapping error to budget for).
    #[test]
    fn x_at_position_round_trips_with_position_at() {
        let session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let anchors = session.position_anchors(region, None);
        assert!(anchors.len() >= 2, "at least two onsets to fix a scale");
        let (_, _, origin_y) = region_staff_line(&session, region);
        let y = origin_y + 1.0;
        let step = MusicalDuration(anchors[1].0 .0.sub(&anchors[0].0 .0));
        let grid = GridResolution { step };

        for (onset, x) in &anchors {
            let gp = session
                .position_at(Point::new(*x, y), &grid)
                .expect("a metric region under the click");
            assert_eq!(&gp.position, onset, "the click snaps to this onset exactly");
            let x_back = session
                .x_at_position(region, None, &gp.position)
                .expect("the forward map resolves");
            assert!(
                (x_back - x).abs() < 1e-2,
                "round trip at onset {onset:?}: {x_back} vs {x}"
            );
        }

        let (last_onset, last_x) = anchors.last().cloned().unwrap();
        let gap = last_x - anchors[anchors.len() - 2].1;
        let reach = last_x + gap * 3.0; // three exact grid steps past the last note
        let gp = session
            .position_at(Point::new(reach, y), &grid)
            .expect("empty space past the notes still resolves");
        assert!(gp.position > last_onset, "past the last onset");
        let x_back = session
            .x_at_position(region, None, &gp.position)
            .expect("the forward map extrapolates past the last anchor too");
        assert!(
            (x_back - reach).abs() < 1e-2,
            "extrapolated round trip: {x_back} vs {reach}"
        );
    }

    /// t5 (segment selection, direct): `valid_score`'s onsets are uniformly
    /// spaced, so every segment shares one slope and
    /// `x_at_position_round_trips_with_position_at`'s extrapolation cannot
    /// distinguish "the last segment" from "the wrong segment" — both give the
    /// same answer on a straight line. This calls [`forward_x`] directly with
    /// deliberately **non-uniform** anchors so a wrong-segment bug is
    /// observable: extrapolating past the last anchor must use the *last*
    /// segment's own slope, not the first's.
    #[test]
    fn forward_x_extrapolates_from_the_last_segment_not_the_first() {
        let p = |n: i64, d: i64| MusicalPosition(RationalTime::new(n, d).unwrap());
        // Segment 0 (onset 0 -> 1/4): slope 40 x per whole note.
        // Segment 1 (onset 1/4 -> 1/2, the *last* segment): slope 8 x per whole note.
        let anchors = vec![(p(0, 1), 0.0_f32), (p(1, 4), 10.0), (p(1, 2), 12.0)];
        // One more 1/4 step past the last anchor.
        let target = p(3, 4);
        let x = forward_x(&anchors, &target);
        assert!(
            (x - 14.0).abs() < 1e-4,
            "extrapolating from the last segment's slope (8 x/whole-note) from \
             (1/2, 12.0) by 1/4 more gives 14.0, got {x}"
        );
    }

    /// t6 (undo): undo restores the score but leaves the caret exactly where
    /// the entry advanced it -- undo does not move the caret (contract-pinned).
    #[test]
    fn undo_restores_the_score_but_leaves_the_caret_advanced() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before_score = session.score().clone();
        let before_events = voice_events(&session, voice);

        let (_, last_pos, last_dur) = before_events.last().unwrap().clone();
        let start = MusicalPosition(last_pos.0.add(&last_dur.0));
        session.caret = Some(Caret {
            voice,
            position: start.clone(),
            entry_duration: dur(1, 4),
        });
        session.enter_rest().expect("enters at the caret");
        let advanced = session.caret().expect("the caret is still set");
        assert_eq!(
            advanced.position,
            MusicalPosition(start.0.add(&RationalTime::new(1, 4).unwrap())),
            "the caret advanced by the entry duration"
        );
        assert_ne!(
            session.score(),
            &before_score,
            "the entry changed the score"
        );

        session.undo().expect("undoes the entry");
        assert_eq!(session.score(), &before_score, "undo restores the score");
        assert_eq!(
            session.caret(),
            Some(advanced),
            "undo does not move the caret"
        );
    }

    /// t6 (vanish): a caret whose voice is deleted out from under it clears.
    /// Built with a **real** op -- `DeleteVoiceOp` exists in `epiphany-ops` and
    /// accepts an empty voice (Chapter 6 §6.10 DeleteVoice) -- rather than a
    /// synthetic internal score swap: the contract asked for the synthetic
    /// construction only "if no current op can remove a voice", and one does.
    #[test]
    fn caret_clears_when_its_voice_is_deleted() {
        use epiphany_core::Voice;
        use epiphany_ops::DeleteVoiceOp;

        let mut score = valid_score(0x5EED);
        let fresh_voice: VoiceId = score.identity.mint();
        {
            let staff_instance = &mut score.canvas.regions[0]
                .content
                .staff_based_mut()
                .expect("the fixture's region is staff-based")
                .staff_instances[0];
            staff_instance.voices.push(Voice::user(fresh_voice));
        }
        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        session.caret = Some(Caret {
            voice: fresh_voice,
            position: MusicalPosition::origin(),
            entry_duration: dur(1, 4),
        });
        assert!(session.caret().is_some());

        session
            .apply(OperationKind::DeleteVoice(DeleteVoiceOp {
                voice: fresh_voice,
            }))
            .expect("deletes the empty voice");

        assert_eq!(
            session.caret(),
            None,
            "the caret cleared when its voice vanished"
        );
    }

    /// Selects the first pitch (notehead) of `event` and returns its id.
    fn select_first_pitch_of(session: &mut EditorSession, event: EventId) -> PitchId {
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        session
            .score()
            .events
            .get(event)
            .expect("event present")
            .collect_identified_pitches(&mut buf);
        let pid = buf.first().expect("a pitch on the event").id;
        select_pitch(session, pid);
        pid
    }

    #[test]
    fn set_selection_duration_shrinks_a_note() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let events = voice_events(&session, voice);
        let (target, pos, _) = events[0].clone();
        select_first_pitch_of(&mut session, target);

        session
            .set_selection_duration(dur(1, 8))
            .expect("shrink applies");
        let after = voice_events(&session, voice);
        let resized = after
            .iter()
            .find(|(id, _, _)| *id == target)
            .expect("the note survives");
        assert_eq!(resized.2, dur(1, 8), "the note is shorter");
        assert_eq!(resized.1, pos, "its onset is unchanged");
        assert_eq!(
            after.len(),
            events.len(),
            "no other events change (the gap is ok)"
        );
    }

    #[test]
    fn set_selection_duration_lengthens_into_empty_space() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let events = voice_events(&session, voice);
        let (last, _, _) = events.last().unwrap().clone();
        select_first_pitch_of(&mut session, last);

        session
            .set_selection_duration(dur(1, 2))
            .expect("lengthen into empty space applies");
        let after = voice_events(&session, voice);
        assert_eq!(
            after.iter().find(|(id, _, _)| *id == last).unwrap().2,
            dur(1, 2)
        );
        assert_eq!(after.len(), events.len(), "no other events change");
    }

    #[test]
    fn set_selection_duration_lengthens_and_deletes_a_covered_note() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let events = voice_events(&session, voice);
        // First note [0, 1/4); next [1/4, 1/2). Lengthen the first to 1/2 → fully covers
        // the next, which is deleted.
        let (first, _, _) = events[0].clone();
        let (second, _, _) = events[1].clone();
        select_first_pitch_of(&mut session, first);

        session
            .set_selection_duration(dur(1, 2))
            .expect("lengthen with overwrite applies");
        let after = voice_events(&session, voice);
        assert_eq!(
            after.iter().find(|(id, _, _)| *id == first).unwrap().2,
            dur(1, 2),
            "lengthened to 1/2"
        );
        assert!(
            !after.iter().any(|(id, _, _)| *id == second),
            "the covered next note was deleted"
        );
        assert_eq!(after.len(), events.len() - 1, "one event removed");
    }

    #[test]
    fn set_selection_duration_lengthen_trims_a_partial_overlap() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let events = voice_events(&session, voice);
        let (first, _, _) = events[0].clone();
        let (second, _, _) = events[1].clone();
        select_first_pitch_of(&mut session, first);

        // Lengthen [0, 1/4) to 3/8 → [0, 3/8) overlaps [1/4, 1/2)'s head → trim it.
        session
            .set_selection_duration(dur(3, 8))
            .expect("partial-overlap lengthen applies");
        let after = voice_events(&session, voice);
        assert_eq!(
            after.iter().find(|(id, _, _)| *id == first).unwrap().2,
            dur(3, 8)
        );
        let trimmed = after
            .iter()
            .find(|(id, _, _)| *id == second)
            .expect("the next note survives, trimmed");
        assert_eq!(
            trimmed.1,
            MusicalPosition(RationalTime::new(3, 8).unwrap()),
            "trimmed to start where the first now ends"
        );
        assert_eq!(trimmed.2, dur(1, 8), "with the remaining 1/8");
        assert_eq!(after.len(), events.len(), "trimmed, not deleted");
    }

    #[test]
    fn set_selection_duration_refuses_non_positive_and_tuplet_members() {
        // Non-positive duration: refused before anything else.
        let mut plain = open_plain(0x5EED);
        let region = a_clean_metric_region(&plain);
        let voice = primary_voice(&plain, region);
        let (note, _, _) = voice_events(&plain, voice)[0].clone();
        select_first_pitch_of(&mut plain, note);
        assert_eq!(
            plain.set_selection_duration(MusicalDuration(RationalTime::zero())),
            Err(EditorError::InvalidDuration)
        );

        // A tuplet member's duration is ratio-governed → refused.
        let mut rich = open_rich(0x5EED);
        let triplet = a_region_with(&rich, true);
        let tvoice = primary_voice(&rich, triplet);
        let (member, _, member_dur) = voice_events(&rich, tvoice)[0].clone();
        select_first_pitch_of(&mut rich, member);
        assert_eq!(
            rich.set_selection_duration(member_dur),
            Err(EditorError::OverlapsTuplet)
        );
    }

    #[test]
    fn set_selection_duration_refuses_a_decomposed_event() {
        use epiphany_core::{
            DecompositionAttachment, DecompositionSource, NotatedComponent, NoteValue,
        };

        let mut score = valid_score(0x5EED);
        let region = score
            .canvas
            .regions
            .iter()
            .find(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
            .unwrap()
            .id;
        let voice = score
            .voices()
            .find(|(r, _, _)| *r == region)
            .map(|(_, _, v)| v.id)
            .unwrap();
        let first = {
            let mut evs: Vec<(EventId, MusicalPosition)> = score
                .events
                .iter()
                .filter(|e| e.voice() == voice)
                .filter_map(|e| match e.position() {
                    EventPosition::Musical(p) => Some((e.id(), p.clone())),
                    _ => None,
                })
                .collect();
            evs.sort_by(|a, b| a.1.cmp(&b.1));
            evs[0].0
        };
        // A valid quarter-note decomposition of the first (quarter) note: changing the
        // note's duration would leave it no longer summing, so the resize is refused.
        score
            .decomposition_attachments
            .push(DecompositionAttachment {
                target: first,
                components: vec![NotatedComponent {
                    base_value: NoteValue::Quarter,
                    dots: 0,
                    tuplet: None,
                    tied_to_next: false,
                }],
                source: DecompositionSource::Inferred,
            });

        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        select_first_pitch_of(&mut session, first);
        assert_eq!(
            session.set_selection_duration(dur(1, 8)),
            Err(EditorError::DecomposedEvent)
        );
    }

    #[test]
    fn set_selection_duration_refuses_a_non_note_event() {
        use epiphany_core::{StaffPosition, UnpitchedEvent, UnpitchedMemberId};

        // Turn the first metric note into an unpitched event (same id/voice/span): it
        // renders as a structural anchor and can be selected, but has no note value to
        // resize, so the duration edit is refused before anything is minted.
        let mut score = valid_score(0x5EED);
        let region = score
            .canvas
            .regions
            .iter()
            .find(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
            .unwrap()
            .id;
        let voice = score
            .voices()
            .find(|(r, _, _)| *r == region)
            .map(|(_, _, v)| v.id)
            .unwrap();
        let first = {
            let mut evs: Vec<(EventId, MusicalPosition)> = score
                .events
                .iter()
                .filter(|e| e.voice() == voice)
                .filter_map(|e| match e.position() {
                    EventPosition::Musical(p) => Some((e.id(), p.clone())),
                    _ => None,
                })
                .collect();
            evs.sort_by(|a, b| a.1.cmp(&b.1));
            evs[0].0
        };
        {
            let ev = score.events.get(first).unwrap();
            let (position, duration) = (ev.position().clone(), ev.duration().clone());
            *score.events.get_mut(first).unwrap() = Event::Unpitched(UnpitchedEvent {
                id: first,
                voice,
                position,
                duration,
                staff_position: StaffPosition(0),
                instrument_member: UnpitchedMemberId(0),
                articulations: Vec::new(),
                dynamic: None,
                stem: StemConfiguration,
                grace: None,
            });
        }

        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let lo = session
            .hit_test()
            .regions
            .iter()
            .find(|r| r.source == TypedObjectId::Event(first))
            .map(|r| r.layout_object)
            .expect("the unpitched event has a hit region");
        session.select(lo).expect("selects the unpitched event");
        assert_eq!(
            session.set_selection_duration(dur(1, 4)),
            Err(EditorError::WrongSelection {
                expected: "note or rest"
            })
        );
    }

    #[test]
    fn set_selection_duration_requires_a_selection() {
        let mut session = open_plain(0x5EED);
        assert_eq!(
            session.set_selection_duration(dur(1, 4)),
            Err(EditorError::NoSelection)
        );
    }

    #[test]
    fn the_full_editing_loop_runs_through_the_session() {
        let mut session = open_rich(0x5EED);
        let before = session.render().clone();

        // Click a notehead, then sharpen the selected pitch — minting the operation
        // is the session's job, not the caller's.
        let selection = click_a_notehead(&mut session);
        let outcome = session.alter_selection(1).expect("the sharpen applies");

        assert!(outcome.graph_changed, "the edit reduced onto the graph");
        assert!(
            outcome.selection_preserved,
            "the selection survived the relayout"
        );
        assert_eq!(
            session.selection().map(|s| s.layout_object),
            Some(selection.layout_object),
            "the selection still anchors the same layout object"
        );
        assert_ne!(&before, session.render(), "the re-render shows the edit");
    }

    #[test]
    fn transpose_requires_a_pitch_selection() {
        let mut session = open_rich(0x5EED);
        // Nothing selected.
        assert_eq!(session.alter_selection(1), Err(EditorError::NoSelection));
        // Select a non-pitch object (a region, present in any score) and try again.
        let non_pitch = session
            .hit_test()
            .regions
            .iter()
            .find(|r| !matches!(r.source, TypedObjectId::Pitch(_)))
            .map(|r| r.layout_object);
        if let Some(id) = non_pitch {
            session.select(id);
            assert_eq!(
                session.alter_selection(1),
                Err(EditorError::WrongSelection { expected: "pitch" })
            );
        }
    }

    #[test]
    fn two_edits_in_a_row_both_apply() {
        // Distinct minted op ids, so the second edit is not deduplicated.
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        assert!(session.alter_selection(1).unwrap().graph_changed);
        let mid = session.score().clone();
        assert!(session.alter_selection(1).unwrap().graph_changed);
        assert_ne!(
            &mid,
            session.score(),
            "the second edit also changed the graph"
        );
    }

    /// A solver whose report is diagnostic-only (`Unsatisfiable`) yet carries a
    /// non-empty layout — which the editor must refuse.
    struct UnsatisfiableSolver;

    impl ConstraintSolver for UnsatisfiableSolver {
        fn tier(&self) -> SolverTier {
            SolverTier::Minimal
        }
        fn version(&self) -> SolverVersion {
            SolverVersion(99)
        }
        fn solve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport {
            let mut report = StubSolver.solve(input, config);
            report.status = SolveStatus::Unsatisfiable;
            report.satisfied_hard_constraints = false;
            report
        }
        fn solve_incremental(
            &self,
            input: &ConstrainedLayoutIR,
            _prior: &SolverState,
            _invalidations: &InvalidationSet,
            config: &SolverConfig,
        ) -> SolveReport {
            self.solve(input, config)
        }
    }

    #[test]
    fn a_diagnostic_only_layout_is_refused_at_open() {
        let opened = EditorSession::open(valid_score_rich(0x5EED), Box::new(UnsatisfiableSolver));
        assert_eq!(opened.err(), Some(EditorError::NotRenderable));
    }

    #[test]
    fn delete_selection_removes_the_note_and_drops_the_selection() {
        let mut session = open_rich(0x5EED);
        let before = session.render().clone();
        let selection = click_a_notehead(&mut session);

        let outcome = session.delete_selection().expect("the delete applies");
        assert!(outcome.graph_changed, "the note was tombstoned");
        // The selected pitch is gone (its event degraded to a rest or lost a chord
        // note), so its layout object no longer exists and the selection is cleared.
        assert!(!outcome.selection_preserved);
        assert_eq!(session.selection(), None);
        assert!(!session
            .hit_test()
            .regions
            .iter()
            .any(|r| r.layout_object == selection.layout_object));
        assert_ne!(&before, session.render(), "the delete changed the render");
    }

    #[test]
    fn delete_selection_requires_a_selection() {
        let mut session = open_rich(0x5EED);
        assert_eq!(session.delete_selection(), Err(EditorError::NoSelection));
    }

    #[test]
    fn move_selection_staff_step_moves_the_note_and_keeps_the_selection() {
        let mut session = open_rich(0x5EED);
        let before_render = session.render().clone();
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let before = session
            .current_pitch(pid)
            .expect("the selected pitch is live");

        let outcome = session
            .move_selection_staff_step(1)
            .expect("the move applies");
        assert!(outcome.graph_changed, "the move changed the pitch");
        assert!(outcome.selection_preserved, "the note keeps its identity");
        assert_eq!(
            session.selection().map(|s| s.layout_object),
            Some(selection.layout_object),
            "the selection still anchors the same notehead"
        );
        let after = session.current_pitch(pid).expect("the pitch is still live");
        assert_eq!(
            after,
            staff_step(&before, 1).unwrap(),
            "the pitch moved exactly one staff step up"
        );
        assert_ne!(
            &before_render,
            session.render(),
            "the note moved on the staff"
        );
    }

    #[test]
    fn move_selection_staff_step_requires_a_pitch_selection() {
        let mut session = open_rich(0x5EED);
        assert_eq!(
            session.move_selection_staff_step(1),
            Err(EditorError::NoSelection)
        );
    }

    #[test]
    fn staff_step_carries_the_octave_and_keeps_the_accidental() {
        // A real fixture pitch is a convenient template (it spares hand-building the
        // acoustic identity); only its staff position is what the step touches.
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let mut p = session
            .current_pitch(pid)
            .expect("the selected pitch is live");

        // C#4 up one staff step → D#4: the letter advances, the sharp is preserved.
        p.scale_position.position = PitchSpacePosition::Cmn {
            nominal: CmnNominal::C,
            alteration: 1,
            octave: 4,
        };
        assert!(matches!(
            staff_step(&p, 1).unwrap().scale_position.position,
            PitchSpacePosition::Cmn {
                nominal: CmnNominal::D,
                alteration: 1,
                octave: 4
            }
        ));
        // B4 up → C5: the octave carries up at the boundary.
        p.scale_position.position = PitchSpacePosition::Cmn {
            nominal: CmnNominal::B,
            alteration: 0,
            octave: 4,
        };
        assert!(matches!(
            staff_step(&p, 1).unwrap().scale_position.position,
            PitchSpacePosition::Cmn {
                nominal: CmnNominal::C,
                octave: 5,
                ..
            }
        ));
        // C4 down → B3: and down.
        p.scale_position.position = PitchSpacePosition::Cmn {
            nominal: CmnNominal::C,
            alteration: 0,
            octave: 4,
        };
        assert!(matches!(
            staff_step(&p, -1).unwrap().scale_position.position,
            PitchSpacePosition::Cmn {
                nominal: CmnNominal::B,
                octave: 3,
                ..
            }
        ));
    }

    #[test]
    fn move_with_an_authored_spelling_rebases_it_atomically() {
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        // Pin the pitch with an explicit user spelling (D4) — what a manual respell
        // leaves. Before this step, a move would have refused.
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: pid,
                spelling: PitchSpelling::cmn(CmnNominal::D, 4),
            }))
            .expect("the respell applies");
        let before_value = session.current_pitch(pid).unwrap();
        let logged = session.applied_operations().len();

        let outcome = session
            .move_selection_staff_step(1)
            .expect("the override-aware move applies");
        assert!(outcome.graph_changed);
        assert!(outcome.selection_preserved, "the moved pitch survives");

        // Both the value and the pinned spelling moved one staff step: the pitch value
        // steps up, and the override D4 → E4.
        assert_eq!(
            session.current_pitch(pid).unwrap(),
            staff_step(&before_value, 1).unwrap(),
            "the pitch value moved one staff step"
        );
        let spelling = authored_spelling(session.score(), pid).expect("still overridden");
        assert!(matches!(
            spelling.nominal,
            SpellingNominal::Cmn(CmnNominal::E)
        ));
        assert_eq!(spelling.octave, 4, "the override moved D4 → E4");

        // It landed as one atomic transaction: a descriptor plus two members.
        assert_eq!(session.applied_operations().len(), logged + 3);
    }

    #[test]
    fn staff_step_spelling_carries_the_octave_and_keeps_accidentals() {
        // B4 (with an accidental stack) up one step → C5, accidentals + octave carry.
        let b4 = PitchSpelling {
            nominal: SpellingNominal::Cmn(CmnNominal::B),
            accidentals: PitchSpelling::cmn(CmnNominal::B, 4).accidentals,
            octave: 4,
            render_hints: Default::default(),
        };
        let up = staff_step_spelling(&b4, 1).expect("a CMN spelling steps");
        assert!(matches!(up.nominal, SpellingNominal::Cmn(CmnNominal::C)));
        assert_eq!(up.octave, 5);
        assert_eq!(
            up.accidentals, b4.accidentals,
            "the accidental stack is kept"
        );
    }

    #[test]
    fn an_imported_or_propagated_override_pins_a_move_too() {
        // The resolver ranks any explicit override that outranks Inferred — not just
        // user-chosen ones — so the refusal predicate must mirror the precedence, not
        // a single source. A propagated override (default rank above Inferred) pins.
        use epiphany_core::{
            PitchSpelling, SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource,
        };

        let mut score = valid_score_rich(0x5EED);
        let pid = *score.live_pitch_ids().iter().next().expect("a live pitch");
        // Baseline: an unspelled live pitch is not pinned.
        assert!(authored_spelling(&score, pid).is_none());

        score.spelling_attachments.push(SpellingAttachment {
            scope: SpellingScope::Pitch(pid),
            directive: SpellingDirective::Explicit(PitchSpelling::cmn(CmnNominal::C, 4)),
            source: SpellingSource::Propagated { from: pid },
            priority: 0,
            layer: None,
        });
        assert!(
            authored_spelling(&score, pid).is_some(),
            "a propagated explicit override outranks Inferred and pins the notehead"
        );
    }

    #[test]
    fn sequential_staff_step_moves_replay_without_structural_conflict() {
        // Two moves on one notehead are sequential local edits to the same target,
        // each a distinct-value ModifyIdentifiedPitch (the note climbs, e.g. C→D→E).
        // The second envelope covers the first, so replaying the emitted op log
        // reduces them as intentional overwrites — no StructuralFieldCollision and
        // nothing held pending. This is the modify path the per-edit causal context
        // exists for (a transpose would not exercise the concurrent() check).
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.move_selection_staff_step(1).expect("first move");
        session.move_selection_staff_step(1).expect("second move");

        let log = session.applied_operations();
        assert_eq!(log.len(), 2);

        let base = valid_score_rich(0x5EED);
        let mut set = OperationSet::new();
        for env in log {
            set.accept(env.clone());
        }
        let materialized = set.reduce_onto(&base);
        assert!(
            materialized.state.is_clean(),
            "sequential same-target moves must replay without a conflict or pending op"
        );
    }

    #[test]
    fn advisory_violating_edit_is_refused_in_authoring_but_reduces_in_replay() {
        // Chapter 6 §"Validation Modes": the session is authoring mode — an
        // advisory-violating operation is refused before an envelope is minted
        // — while the same operation, minted elsewhere, reduces cleanly through
        // raw OperationSet reduction (replay mode enforces only invariant
        // preconditions; advisory ones fail silently).
        use epiphany_core::{
            AnchorOffset, MusicalDuration, MusicalPosition, RationalTime, RegionEdge, TimeAnchor,
            WallClockTime,
        };

        // The plain fixture, with its single region given a *musical* end
        // bound of 12 whole units (the fixture's own extent is wall-clock,
        // which the advisory boundary check cannot resolve).
        let mut score = valid_score(0x5EED);
        let region_id = score.canvas.regions[0].id;
        score.canvas.regions[0].time_extent.end = TimeAnchor::Region {
            id: region_id,
            edge: RegionEdge::Start,
            offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(12))),
        };
        let instance = score.canvas.regions[0].staff_instances()[0].id;
        let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id;

        // An insert whose span straddles the bound: starts at 10, ends at 14 —
        // applying it would require splitting the event across the boundary.
        let event_id = EventId::new(ReplicaId(50), 999);
        let kind = OperationKind::InsertEvent(InsertEventOp {
            staff_instance: instance,
            event: epiphany_ops::valuegen::insert_event_value(
                event_id,
                voice,
                MusicalPosition(RationalTime::from_int(10)),
                MusicalDuration(RationalTime::from_int(4)),
                &[PitchId::new(ReplicaId(50), 998)],
            ),
        });

        // Authoring: refused pre-mint, on both the single-op and the
        // transaction seam; the op log stays untouched.
        let mut session =
            EditorSession::open(score.clone(), Box::new(StubSolver)).expect("the fixture renders");
        let err = session
            .apply(kind.clone())
            .expect_err("an advisory violation must refuse the edit");
        assert!(matches!(err, EditorError::AdvisoryViolation { .. }));
        assert!(session.applied_operations().is_empty());
        let err = session
            .apply_transaction("cross-boundary insert", None, vec![kind.clone()])
            .expect_err("an advisory-violating member must refuse the transaction");
        assert!(matches!(err, EditorError::AdvisoryViolation { .. }));
        assert!(session.applied_operations().is_empty());

        // Replay: the same operation in an envelope reduces cleanly and the
        // event materializes — the advisory check has no channel into
        // reduction.
        let id = epiphany_core::OperationId::new(ReplicaId(50), 0);
        let env = OperationEnvelope {
            id,
            author: AuthorId(7),
            stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
            causal_context: CausalContext::new(),
            transaction: None,
            payload: OperationPayload::Primitive(kind),
        };
        let mut set = OperationSet::new();
        assert_eq!(set.accept(env), AcceptOutcome::Accepted);
        let materialized = set.reduce_onto(&score);
        assert!(
            materialized.state.is_clean(),
            "replay applies the advisory-violating insert cleanly: {:?}",
            materialized.state
        );
        assert!(
            materialized.score.events.get(event_id).is_some(),
            "the event materializes under replay"
        );
    }

    #[test]
    fn add_note_to_selection_adds_a_chord_note_above_the_top() {
        let mut session = open_rich(0x5EED);
        let before_render = session.render().clone();
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(anchor) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let (event, _) = session.event_and_pitch_of(anchor).expect("anchor is live");
        let (_, rendered_top) = session.rendered_top_of_event(event).expect("a CMN note");
        let before = session.score().live_pitch_ids();

        let outcome = session.add_note_to_selection().expect("the add applies");
        assert!(outcome.graph_changed, "a pitch was inserted");
        assert!(outcome.selection_preserved, "the anchor notehead survives");

        // Exactly one new pitch, minted under the session replica, one staff step above
        // the rendered-top note's resolved position.
        let after = session.score().live_pitch_ids();
        let new_ids: Vec<_> = after.difference(&before).copied().collect();
        assert_eq!(new_ids.len(), 1, "exactly one new pitch");
        let new_id = new_ids[0];
        assert_eq!(
            new_id.replica(),
            ReplicaId(1),
            "minted under the session replica"
        );
        assert_eq!(
            diatonic_index(&session.current_pitch(new_id).unwrap()),
            Some(rendered_top + 1),
            "the new note's staff position is one step above the rendered top"
        );
        assert_ne!(&before_render, session.render(), "the chord note shows");
    }

    #[test]
    fn add_note_to_selection_requires_a_pitch_selection() {
        let mut session = open_rich(0x5EED);
        assert_eq!(
            session.add_note_to_selection(),
            Err(EditorError::NoSelection)
        );
    }

    #[test]
    fn re_minting_skips_an_id_used_earlier_in_the_session_log() {
        // Add a chord pitch, delete it, add again. The deleted pitch leaves no trace
        // in the materialized score (graph_delete_pitch does not record it in
        // tombstoned_pitches), so a score-only minter would re-mint it — and the
        // re-add would then no-op against the tombstone under whole-log reduction.
        // The minter consults the op log, so the second add gets a fresh id.
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.add_note_to_selection().expect("first add");
        let first = *session
            .score()
            .live_pitch_ids()
            .iter()
            .find(|p| p.replica() == ReplicaId(1))
            .expect("the inserted pitch is under the session replica");

        session
            .apply(OperationKind::DeleteIdentifiedPitch(
                DeleteIdentifiedPitchOp { pitch: first },
            ))
            .expect("the delete applies");

        let outcome = session.add_note_to_selection().expect("the re-add applies");
        assert!(
            outcome.graph_changed,
            "the re-add materialized — a fresh id, not the tombstoned one"
        );
        let live_self: Vec<_> = session
            .score()
            .live_pitch_ids()
            .into_iter()
            .filter(|p| p.replica() == ReplicaId(1))
            .collect();
        assert_eq!(
            live_self,
            vec![PitchId::new(ReplicaId(1), first.counter() + 1)],
            "the re-add minted the next counter, not the deleted id"
        );
    }

    #[test]
    fn re_minting_skips_a_deleted_base_pitch_under_the_session_replica() {
        // The reopen case: the session edits under the same replica that authored some
        // base pitches. Deleting the highest-counter base pitch removes it from the
        // current score without recording it anywhere there, so a score-only minter
        // would reuse its counter. Scanning the pristine `base` keeps the high-water
        // mark.
        let base = valid_score_rich(0x5EED);
        let target = *base
            .live_pitch_ids()
            .iter()
            .max_by_key(|p| p.counter())
            .expect("the fixture has live pitches");
        let replica = target.replica();
        let base_max = base
            .live_pitch_ids()
            .into_iter()
            .chain(base.tombstoned_pitches.iter().copied())
            .filter(|p| p.replica() == replica)
            .map(|p| p.counter())
            .max()
            .unwrap();

        let mut session = EditorSession::open(base, Box::new(StubSolver))
            .unwrap()
            .with_identity(replica, AuthorId(0));
        session
            .apply(OperationKind::DeleteIdentifiedPitch(
                DeleteIdentifiedPitchOp { pitch: target },
            ))
            .expect("the base pitch deletes");

        let minted = session.mint_pitch_id();
        assert_eq!(
            minted,
            PitchId::new(replica, base_max + 1),
            "minted past the base high-water mark, not reusing the deleted pitch"
        );
        assert_ne!(minted, target, "the deleted base pitch was not reused");
    }

    #[test]
    fn add_to_a_respelled_note_stacks_above_the_rendered_position() {
        use epiphany_core::PitchSpelling;
        use epiphany_ops::RespellPitchOp;

        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(anchor) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let raw = diatonic_index(&session.current_pitch(anchor).unwrap()).expect("a CMN pitch");
        // Pin the note three staff steps above its raw pitch position, so the rendered
        // staff order diverges from the raw pitch order.
        let rendered = raw + 3;
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: anchor,
                spelling: PitchSpelling::cmn(
                    nominal_from_index(rendered.rem_euclid(7)),
                    rendered.div_euclid(7) as i8,
                ),
            }))
            .expect("the respell applies");

        // The add no longer refuses; it stacks one step above the *rendered* position
        // (raw + 4), not above the raw pitch position (raw ranking would give raw + 1).
        let before = session.score().live_pitch_ids();
        session
            .add_note_to_selection()
            .expect("the override-aware add applies");
        let new_id = *session
            .score()
            .live_pitch_ids()
            .difference(&before)
            .next()
            .expect("a new pitch");
        assert_eq!(
            diatonic_index(&session.current_pitch(new_id).unwrap()),
            Some(rendered + 1),
            "stacked above the rendered (respelled) position, not the raw one"
        );
    }

    #[test]
    fn an_inserted_note_sounds_at_its_written_position_not_a_cloned_frequency() {
        // staff_step preserves acoustic realization (intended for notation-only
        // moves); a freshly inserted note must not inherit an explicit absolute
        // frequency from the note it stacks above — it would look higher but sound
        // identical. note_above resets the realization to Implicit.
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(anchor) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let mut top = session.current_pitch(anchor).expect("anchor is live");
        top.acoustic.realization = AcousticRealization::absolute_hz(440.0).unwrap();

        let inserted = note_stepped(&top, 1).expect("a CMN note steps");
        assert_eq!(
            inserted.acoustic.realization,
            AcousticRealization::Implicit,
            "the new note drops the source's explicit frequency"
        );
        assert!(
            diatonic_index(&inserted) > diatonic_index(&top),
            "and it is genuinely a staff step higher"
        );
    }

    #[test]
    fn repeated_adds_mint_distinct_fresh_ids_and_build_a_rising_chord() {
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        let before = session.score().live_pitch_ids();

        session.add_note_to_selection().expect("first add");
        session.add_note_to_selection().expect("second add");

        let after = session.score().live_pitch_ids();
        let mut new_ids: Vec<_> = after.difference(&before).copied().collect();
        assert_eq!(new_ids.len(), 2, "two distinct pitches added");
        // Both minted under the session replica, with distinct (advancing) counters —
        // ids are never reused.
        assert!(new_ids.iter().all(|p| p.replica() == ReplicaId(1)));
        new_ids.sort_by_key(|p| p.counter());
        assert_ne!(new_ids[0].counter(), new_ids[1].counter());
        // The second add stacked above the first: strictly higher staff position.
        let lo = session.current_pitch(new_ids[0]).unwrap();
        let hi = session.current_pitch(new_ids[1]).unwrap();
        assert!(
            diatonic_index(&hi) > diatonic_index(&lo),
            "the chord rises rather than duplicating a position"
        );
    }

    #[test]
    fn insert_note_after_selection_adds_a_following_event() {
        let mut session = open_rich(0x5EED);
        let before_render = session.render().clone();
        let anchor = last_event_pitch(&session);
        select_pitch(&mut session, anchor);

        // The anchor event's voice / end position / value, before the insert.
        let (anchor_event, anchor_value) = session.event_and_pitch_of(anchor).unwrap();
        let ev = session.score().events.get(anchor_event).unwrap();
        let anchor_voice = ev.voice();
        let (EventPosition::Musical(pos), EventDuration::Musical(dur)) =
            (ev.position().clone(), ev.duration().clone())
        else {
            panic!("the fixture's events are metric");
        };
        let expected_position = pos + dur;
        let before_ids: std::collections::BTreeSet<_> =
            session.score().events.iter().map(Event::id).collect();

        let outcome = session
            .insert_note_after_selection()
            .expect("the insert applies after the last note");
        assert!(outcome.graph_changed, "a new event was inserted");
        assert!(outcome.selection_preserved, "the anchor note survives");

        // Exactly one new event, in the anchor's voice, at the next position, with a
        // fresh id under the session replica and a single note copying the anchor.
        let new_eid = session
            .score()
            .events
            .iter()
            .map(Event::id)
            .find(|e| !before_ids.contains(e))
            .expect("a new event");
        assert_eq!(new_eid.replica(), ReplicaId(1));
        let new_event = session.score().events.get(new_eid).unwrap();
        assert_eq!(new_event.voice(), anchor_voice, "same voice as the anchor");
        assert!(
            matches!(new_event.position(), EventPosition::Musical(p) if *p == expected_position),
            "placed immediately after the anchor"
        );
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        new_event.collect_identified_pitches(&mut buf);
        assert_eq!(buf.len(), 1, "a single inserted note");
        assert_eq!(buf[0].pitch, anchor_value, "copies the selected pitch");
        assert_ne!(&before_render, session.render(), "the new note shows");
    }

    #[test]
    fn insert_after_an_already_filled_slot_is_refused() {
        let mut session = open_rich(0x5EED);
        let anchor = last_event_pitch(&session);
        select_pitch(&mut session, anchor);
        // The first insert fills the slot right after the anchor.
        session
            .insert_note_after_selection()
            .expect("the first insert applies");
        let logged = session.applied_operations().len();
        // The selection still anchors the same note, so a second insert-after targets
        // the now-occupied slot and is refused (not silently no-op'd).
        assert_eq!(
            session.insert_note_after_selection(),
            Err(EditorError::InsertSlotOccupied)
        );
        // A pre-apply refusal mints/logs nothing — no dead op is appended.
        assert_eq!(session.applied_operations().len(), logged);
    }

    #[test]
    fn insert_after_an_overridden_pitch_carries_the_spelling() {
        let mut session = open_rich(0x5EED);
        let anchor = last_event_pitch(&session);
        select_pitch(&mut session, anchor);
        // Pin the anchor's spelling (C4). The copy must carry it, not drop it.
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: anchor,
                spelling: PitchSpelling::cmn(CmnNominal::C, 4),
            }))
            .expect("the respell applies");
        let before_ids: std::collections::BTreeSet<_> =
            session.score().events.iter().map(Event::id).collect();

        let outcome = session
            .insert_note_after_selection()
            .expect("the override-carrying insert applies");
        assert!(outcome.graph_changed);

        // The new event's note carries the same authored spelling (C4).
        let new_eid = session
            .score()
            .events
            .iter()
            .map(Event::id)
            .find(|e| !before_ids.contains(e))
            .expect("a new event");
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        session
            .score()
            .events
            .get(new_eid)
            .unwrap()
            .collect_identified_pitches(&mut buf);
        let new_pitch = buf[0].id;
        let spelling =
            authored_spelling(session.score(), new_pitch).expect("the copy carries an override");
        assert!(matches!(
            spelling.nominal,
            SpellingNominal::Cmn(CmnNominal::C)
        ));
        assert_eq!(
            spelling.octave, 4,
            "the copy's spelling matches the original"
        );
    }

    #[test]
    fn insert_note_after_selection_requires_a_pitch_selection() {
        let mut session = open_rich(0x5EED);
        assert_eq!(
            session.insert_note_after_selection(),
            Err(EditorError::NoSelection)
        );
    }

    #[test]
    fn successive_inserts_mint_distinct_event_ids() {
        let mut session = open_rich(0x5EED);
        let anchor = last_event_pitch(&session);
        select_pitch(&mut session, anchor);
        let before_ids: std::collections::BTreeSet<_> =
            session.score().events.iter().map(Event::id).collect();

        // Insert after the anchor, then after the new (now last) event.
        session.insert_note_after_selection().expect("first insert");
        let first_new = session
            .score()
            .events
            .iter()
            .map(Event::id)
            .find(|e| !before_ids.contains(e))
            .expect("the first new event");
        let next_anchor = last_event_pitch(&session);
        select_pitch(&mut session, next_anchor);
        session
            .insert_note_after_selection()
            .expect("second insert");

        let self_ids: Vec<_> = session
            .score()
            .events
            .iter()
            .map(Event::id)
            .filter(|e| e.replica() == ReplicaId(1))
            .collect();
        assert_eq!(self_ids.len(), 2, "two session-minted events");
        // The minter consulted the log: the first event id survived and the second
        // is a distinct, never-reused id.
        assert!(self_ids.contains(&first_new), "the first event id survived");
        assert_ne!(self_ids[0], self_ids[1], "distinct, never reused");
    }

    #[test]
    fn apply_transaction_lands_all_members_atomically() {
        use epiphany_core::PitchSpelling;
        use epiphany_ops::RespellPitchOp;

        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pitch) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let current = session.current_pitch(pitch).expect("the pitch is live");
        let moved = staff_step(&current, 1).expect("a CMN pitch");

        // A value change and a matching respelling, together.
        let outcome = session
            .apply_transaction(
                "move note",
                Some(TransactionCategory::NoteEntry),
                vec![
                    OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
                        pitch,
                        value: moved.clone(),
                    }),
                    OperationKind::RespellPitch(RespellPitchOp {
                        pitch,
                        spelling: PitchSpelling::cmn(CmnNominal::D, 4),
                    }),
                ],
            )
            .expect("the transaction applies");

        assert!(outcome.graph_changed);
        assert!(outcome.selection_preserved, "the edited pitch survives");
        // Both members materialized: the value moved and an authored override landed.
        assert_eq!(session.current_pitch(pitch).unwrap(), moved);
        assert!(authored_spelling(session.score(), pitch).is_some());

        // The log holds the descriptor plus two members under one transaction id.
        let log = session.applied_operations();
        assert_eq!(log.len(), 3);
        let tx_id = log
            .iter()
            .find_map(declared_transaction_id)
            .expect("a declared transaction");
        let members = log.iter().filter(|e| e.transaction == Some(tx_id)).count();
        assert_eq!(members, 2, "two members reference the transaction id");
    }

    #[test]
    fn a_transaction_replays_as_a_clean_atomic_unit() {
        use epiphany_core::PitchSpelling;
        use epiphany_ops::RespellPitchOp;

        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pitch) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let moved = staff_step(&session.current_pitch(pitch).unwrap(), 1).unwrap();
        session
            .apply_transaction(
                "move note",
                None,
                vec![
                    OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
                        pitch,
                        value: moved,
                    }),
                    OperationKind::RespellPitch(RespellPitchOp {
                        pitch,
                        spelling: PitchSpelling::cmn(CmnNominal::D, 4),
                    }),
                ],
            )
            .unwrap();

        // Replaying the emitted log reduces with no conflict and nothing pending: the
        // descriptor-precedence rule holds (members cover the descriptor) and the
        // block applies atomically.
        let base = valid_score_rich(0x5EED);
        let mut set = OperationSet::new();
        for env in session.applied_operations() {
            set.accept(env.clone());
        }
        let materialized = set.reduce_onto(&base);
        assert!(
            materialized.state.is_clean(),
            "the transaction replays without a conflict or pending op"
        );
    }

    #[test]
    fn a_transaction_with_a_failing_member_rolls_back_and_is_not_logged() {
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pitch) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let moved = staff_step(&session.current_pitch(pitch).unwrap(), 1).unwrap();
        // A fresh id is absent from the score, so a modify targeting it fails its
        // precondition — and the reducer rolls back the whole transaction.
        let missing = session.mint_pitch_id();
        let before_score = session.score().clone();
        let before_log = session.applied_operations().len();

        let result = session.apply_transaction(
            "bad move",
            None,
            vec![
                OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
                    pitch,
                    value: moved.clone(),
                }),
                OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
                    pitch: missing,
                    value: moved,
                }),
            ],
        );
        assert_eq!(result, Err(EditorError::RejectedOperation));
        assert_eq!(&before_score, session.score(), "the score is unchanged");
        assert_eq!(
            session.applied_operations().len(),
            before_log,
            "a rolled-back transaction logs nothing"
        );
    }

    #[test]
    fn an_empty_transaction_is_refused() {
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        assert_eq!(
            session.apply_transaction("empty", None, vec![]),
            Err(EditorError::EmptyTransaction)
        );
        assert!(
            session.applied_operations().is_empty(),
            "no descriptor-only op is logged"
        );
    }

    #[test]
    fn declare_transaction_is_refused_as_a_direct_edit_and_as_a_member() {
        let declare = || {
            OperationKind::DeclareTransaction(TransactionDescriptor {
                id: TransactionId::new(ReplicaId(1), 0),
                label: "decl".to_string(),
                category: None,
            })
        };

        // Directly through apply: the session manages transaction declaration.
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        assert_eq!(
            session.apply(declare()),
            Err(EditorError::DeclareTransactionNotAllowed)
        );
        assert!(
            session.applied_operations().is_empty(),
            "a direct declaration logs nothing"
        );

        // And as a transaction member (a nested no-op declaration).
        assert_eq!(
            session.apply_transaction("outer", None, vec![declare()]),
            Err(EditorError::DeclareTransactionNotAllowed)
        );
        assert!(
            session.applied_operations().is_empty(),
            "a nested declaration logs nothing"
        );
    }

    #[test]
    fn applied_operations_log_records_each_successful_edit_in_order() {
        let mut session = open_rich(0x5EED);
        assert!(session.applied_operations().is_empty());
        assert!(session.last_applied().is_none());

        click_a_notehead(&mut session);
        session.alter_selection(1).unwrap();
        session.alter_selection(-1).unwrap();

        // Two edits, two log entries, distinct ids, in application order, and
        // last_applied is the tail.
        let log = session.applied_operations();
        assert_eq!(log.len(), 2);
        assert_ne!(log[0].id, log[1].id);
        assert_eq!(session.last_applied(), Some(&log[1]));
        // The log carries real envelopes a peer/undo layer can consume — and
        // new authoring emits the faithful kind, never the frozen
        // `Transpose` (`req:opcat:transpose-frozen`).
        assert!(matches!(
            log[1].payload,
            OperationPayload::Primitive(OperationKind::TransposeInterval(_))
        ));
    }

    #[test]
    fn transposing_an_octave_moves_the_octave_not_the_alteration() {
        // What the scalar API could never express. Before Push 4a,
        // `transpose_selection(12)` gave C4 with `alteration: 12` — a C with
        // six double-sharps on the same staff line.
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let before = session.current_pitch(pid).unwrap();
        let PitchSpacePosition::Cmn {
            nominal, octave, ..
        } = before.scale_position.position
        else {
            panic!("the fixture is CMN");
        };

        session
            .transpose_selection(TranspositionInterval {
                diatonic_steps: 7,
                chromatic_steps: 12,
            })
            .expect("an octave up");

        let after = session.current_pitch(pid).unwrap();
        assert_eq!(
            after.scale_position.position,
            PitchSpacePosition::Cmn {
                nominal,
                alteration: 0,
                octave: octave + 1,
            }
        );
    }

    #[test]
    fn a_sharpen_keeps_the_staff_line_and_only_adds_the_accidental() {
        // `alter_selection` is the "+1 semitone" key: no diatonic motion.
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let before = session.current_pitch(pid).unwrap();
        let PitchSpacePosition::Cmn {
            nominal,
            alteration,
            octave,
        } = before.scale_position.position
        else {
            panic!("the fixture is CMN");
        };

        session.alter_selection(1).expect("sharpen");

        assert_eq!(
            session.current_pitch(pid).unwrap().scale_position.position,
            PitchSpacePosition::Cmn {
                nominal,
                alteration: alteration + 1,
                octave,
            }
        );
    }

    #[test]
    fn undo_of_a_transpose_restores_the_spelling_as_well_as_the_pitch() {
        // EditorSession::undo re-materializes from a truncated log rather than
        // riding the CRDT UndoTransaction path, so it exercises the reducer's
        // spelling rewrite from the other side: the transpose must simply not
        // happen, attachments and all.
        use epiphany_core::{SpellingScope, SpellingSource};
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let before = session.current_pitch(pid).unwrap();

        session.alter_selection(1).expect("sharpen");
        assert!(session.score().spelling_attachments.iter().any(|a| {
            matches!(a.source, SpellingSource::Propagated { .. })
                && matches!(&a.scope, SpellingScope::Pitch(p) if *p == pid)
        }));

        session.undo().expect("undo");
        assert_eq!(session.current_pitch(pid).unwrap(), before);
        assert!(
            !session.score().spelling_attachments.iter().any(|a| {
                matches!(a.source, SpellingSource::Propagated { .. })
                    && matches!(&a.scope, SpellingScope::Pitch(p) if *p == pid)
            }),
            "the propagated attachment did not survive the undo"
        );
    }

    #[test]
    fn undo_and_redo_a_transpose() {
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        let before = session.current_pitch(pid).unwrap();
        session.alter_selection(1).expect("sharpen");
        let after = session.current_pitch(pid).unwrap();
        assert_ne!(before, after);
        assert!(session.can_undo() && !session.can_redo());

        let outcome = session.undo().expect("undo");
        assert!(outcome.graph_changed);
        assert_eq!(
            session.current_pitch(pid).unwrap(),
            before,
            "undo restores the value"
        );
        assert!(!session.can_undo() && session.can_redo());

        session.redo().expect("redo");
        assert_eq!(
            session.current_pitch(pid).unwrap(),
            after,
            "redo re-applies the edit"
        );
        assert!(session.can_undo() && !session.can_redo());
    }

    #[test]
    fn undo_restores_a_deleted_note() {
        // The CRDT is delete-wins (a tombstone is permanent), so undo cannot *invert* a
        // delete — it re-reduces the log without it, and the note reappears.
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        assert!(session.score().live_pitch_ids().contains(&pid));
        session.delete_selection().expect("delete");
        assert!(!session.score().live_pitch_ids().contains(&pid), "deleted");

        session.undo().expect("undo");
        assert!(
            session.score().live_pitch_ids().contains(&pid),
            "undo brings the deleted note back"
        );
        session.redo().expect("redo");
        assert!(
            !session.score().live_pitch_ids().contains(&pid),
            "redo deletes it again"
        );
    }

    #[test]
    fn undo_reverts_a_split_insert_as_one_unit() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let (_, _, origin_y) = region_staff_line(&session, region);

        let (_, first_pos, first_dur) = before[0].clone();
        let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
        let target = MusicalPosition(first_pos.0.add(&cell));
        let at = click_for_position(&session, region, &target, origin_y + 1.0);
        session
            .insert_note_at(
                at,
                &GridResolution {
                    step: MusicalDuration(cell),
                },
            )
            .expect("split");
        assert_eq!(voice_events(&session, voice).len(), before.len() + 2);
        assert!(
            session.applied_operations().len() > 1,
            "the split is a multi-op transaction"
        );

        session.undo().expect("undo");
        // Trim-head + tail-insert + new-note all undo together as one unit.
        assert_eq!(
            voice_events(&session, voice),
            before,
            "undo restores the original voice exactly"
        );
        assert_eq!(
            session.applied_operations().len(),
            0,
            "the whole unit is gone"
        );

        session.redo().expect("redo");
        assert_eq!(
            voice_events(&session, voice).len(),
            before.len() + 2,
            "redo re-splits"
        );
    }

    #[test]
    fn undo_restores_a_make_room_overwrite() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let (first, _, _) = before[0].clone();
        let (second, _, _) = before[1].clone();
        select_first_pitch_of(&mut session, first);

        session
            .set_selection_duration(dur(1, 2))
            .expect("lengthen with overwrite");
        assert!(
            !voice_events(&session, voice)
                .iter()
                .any(|(id, _, _)| *id == second),
            "the covered note was deleted"
        );
        session.undo().expect("undo");
        assert_eq!(
            voice_events(&session, voice),
            before,
            "undo restores both the resize and the deleted note"
        );
    }

    #[test]
    fn undo_an_override_aware_move_restores_value_and_spelling() {
        use epiphany_core::PitchSpelling;
        use epiphany_ops::RespellPitchOp;

        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("a notehead selects a pitch");
        };
        // Pin a spelling so the move lands as a value + respell transaction.
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: pid,
                spelling: PitchSpelling::cmn(CmnNominal::D, 4),
            }))
            .expect("respell");
        let value = session.current_pitch(pid).unwrap();
        let spelling = authored_spelling(session.score(), pid).expect("pinned");

        session
            .move_selection_staff_step(1)
            .expect("override-aware move");
        assert_ne!(session.current_pitch(pid).unwrap(), value, "value moved");
        assert_ne!(
            authored_spelling(session.score(), pid),
            Some(spelling.clone()),
            "spelling moved"
        );

        session.undo().expect("undo the move");
        // Both transaction members (the modify and the respell) undo together.
        assert_eq!(session.current_pitch(pid).unwrap(), value, "value restored");
        assert_eq!(
            authored_spelling(session.score(), pid),
            Some(spelling),
            "spelling restored"
        );
    }

    #[test]
    fn a_new_edit_after_undo_forks_history() {
        let mut session = open_rich(0x5EED);
        assert!(!session.can_undo() && !session.can_redo());
        assert!(session.undo().is_none(), "nothing to undo");
        assert!(session.redo().is_none(), "nothing to redo");

        click_a_notehead(&mut session);
        session.alter_selection(1).expect("e1");
        session.alter_selection(1).expect("e2");
        assert_eq!(session.applied_operations().len(), 2);

        session.undo().expect("undo e2");
        assert!(session.can_redo());
        // A new edit past an undo clears the redo stack.
        session.alter_selection(-1).expect("e3");
        assert!(!session.can_redo(), "the new edit forked away the redo");
        assert_eq!(
            session.applied_operations().len(),
            2,
            "e1 + e3 (e2 was undone and forked away)"
        );
    }

    #[test]
    fn a_fork_mints_a_fresh_operation_id_not_the_undone_one() {
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.alter_selection(1).expect("A");
        let a_id = session.last_applied().unwrap().id;
        session.undo().expect("undo A");
        session.alter_selection(-1).expect("B (forks A)");
        let b_id = session.last_applied().unwrap().id;

        assert_ne!(a_id, b_id, "B gets a fresh op id, not A's reused counter");
        // B is in the active prefix; A is forked out of it but kept in authored history.
        assert!(session.applied_operations().iter().any(|e| e.id == b_id));
        assert!(!session.applied_operations().iter().any(|e| e.id == a_id));
        assert!(session.authored_operations().iter().any(|e| e.id == a_id));
        assert_eq!(
            session.authored_operations().len(),
            2,
            "A and B both authored"
        );
    }

    #[test]
    fn a_fork_does_not_reuse_minted_event_ids() {
        let mut session = open_plain(0x5EED);
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let (_, _, origin_y) = region_staff_line(&session, region);
        let opened: std::collections::BTreeSet<EventId> =
            session.score().events.iter().map(|e| e.id()).collect();

        // Insert into empty space (mints a fresh event), then undo it.
        let last = voice_events(&session, voice).last().unwrap().clone();
        let target = MusicalPosition(last.1 .0.add(&last.2 .0));
        let at = click_for_position(&session, region, &target, origin_y + 1.0);
        let grid = GridResolution {
            step: last.2.clone(),
        };
        session.insert_note_at(at, &grid).expect("insert 1");
        let inserted = |s: &EditorSession| -> EventId {
            *s.score()
                .events
                .iter()
                .map(|e| e.id())
                .collect::<std::collections::BTreeSet<_>>()
                .difference(&opened)
                .next()
                .expect("one inserted event")
        };
        let first = inserted(&session);
        session.undo().expect("undo insert 1");

        // Insert again at the same spot: the undone event's id left the score and the
        // active prefix, but it is still in authored history, so a fresh id is minted.
        session.insert_note_at(at, &grid).expect("insert 2");
        let second = inserted(&session);
        assert_ne!(
            second, first,
            "the second insert mints a fresh event id, not the undone one's"
        );
    }

    #[test]
    fn edits_after_a_fork_re_reduce_cleanly() {
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.alter_selection(1).expect("A");
        session.alter_selection(1).expect("B");
        session.undo().expect("undo B");
        // Each edit re-reduces the whole active prefix; if a forked op were stranded
        // pending (a missing-predecessor hole left by a non-contiguous context), the
        // reduction would be unclean and the edit would be refused. So these succeeding
        // is the proof the fork's causal contexts are correct.
        session.alter_selection(-1).expect("C");
        session.alter_selection(-1).expect("D");

        let active = session.applied_operations();
        assert_eq!(active.len(), 3, "A, C, D active; B forked away");
        let ids: std::collections::BTreeSet<_> = active.iter().map(|e| e.id).collect();
        assert_eq!(
            ids.len(),
            3,
            "distinct, monotonic op ids (B's was not reused)"
        );
        assert_eq!(
            session.authored_operations().len(),
            4,
            "A, B, C, D all authored"
        );
    }

    #[test]
    #[should_panic(expected = "with_identity must be set before any edit")]
    fn with_identity_is_refused_after_an_undone_edit() {
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.alter_selection(1).expect("edit");
        session.undo().expect("undo");
        assert!(
            session.applied_operations().is_empty(),
            "the active prefix is empty"
        );
        // ...but the session has authored history, so the identity is still fixed.
        let _ = session.with_identity(ReplicaId(2), AuthorId(0));
    }

    #[test]
    fn each_local_edit_causally_covers_the_prior_one() {
        // The session's edits form one replica's contiguous, zero-based history, so
        // a later edit's envelope causally covers the earlier one. That is what makes
        // two sequential same-target edits (e.g. a diatonic move, which modifies a
        // pitch in place) reduce as intentional overwrites rather than concurrent
        // conflicts — both here and on a peer replaying the log.
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.alter_selection(1).unwrap();
        session.alter_selection(1).unwrap();

        let log = session.applied_operations();
        assert_eq!(log.len(), 2);
        // The first edit is a root: no causal predecessors.
        assert!(
            log[0].causal_context.is_empty(),
            "the first edit has no predecessors"
        );
        // The second covers the first (and shares neither id).
        assert_ne!(log[0].id, log[1].id);
        assert!(
            log[1].causal_context.covers(log[0].id),
            "the second edit causally covers the first"
        );
        // And it still materialized — covering its predecessor must not hold it
        // pending under the missing-predecessor rule (the predecessor is in the set
        // the session reduces).
        assert!(
            session.last_applied().is_some(),
            "the covering edit applied"
        );
    }

    #[test]
    #[should_panic(expected = "before any edit")]
    fn changing_identity_after_an_edit_is_refused() {
        // The op log is one replica's contiguous history; switching identity after
        // an edit would continue the counter under a new replica and open a
        // `(new_replica, 0)` hole. The session refuses it.
        let mut session = open_rich(0x5EED);
        click_a_notehead(&mut session);
        session.alter_selection(1).unwrap();
        let _ = session.with_identity(ReplicaId(2), AuthorId(0));
    }

    #[test]
    fn a_rejected_edit_is_not_logged() {
        use epiphany_core::ReplicaId;
        let mut session = open_rich(0x5EED).with_identity(ReplicaId::SYSTEM_DERIVED, AuthorId(0));
        click_a_notehead(&mut session);
        assert_eq!(
            session.alter_selection(1),
            Err(EditorError::RejectedOperation)
        );
        assert!(
            session.applied_operations().is_empty(),
            "a rejected edit leaves the op log untouched"
        );
    }

    #[test]
    fn a_rejected_operation_is_an_error_not_a_silent_no_op() {
        use epiphany_core::ReplicaId;
        // Minting under the reserved replica makes every operation ill-formed.
        let mut session = EditorSession::open(valid_score_rich(0x5EED), Box::new(StubSolver))
            .unwrap()
            .with_identity(ReplicaId::SYSTEM_DERIVED, AuthorId(0));
        click_a_notehead(&mut session);
        let before = session.score().clone();

        // The edit is rejected (not a silent Ok/graph_changed=false), and nothing —
        // not even the operation counter — mutates, so a later valid edit still
        // works.
        assert_eq!(
            session.alter_selection(1),
            Err(EditorError::RejectedOperation)
        );
        assert_eq!(
            &before,
            session.score(),
            "a rejected edit leaves the document untouched"
        );
    }

    // --- The edit-barrier gate (Chapter 8 §"Behavior Under Unknown Extensions") ---

    use epiphany_layout_ir::{BarrierCondition, BarrierScope, EditBarrier, ObjectKind};

    /// A whole-score barrier prohibiting one operation class (no object-kind or
    /// condition narrowing), as extension `ext` would declare it.
    fn extension_prohibiting(ext: u128, op: OperationKindTag) -> ActiveExtension {
        ActiveExtension {
            extension: ExtensionRef(ext),
            barriers: vec![EditBarrier {
                scope: BarrierScope::WholeScore,
                affected_object_kinds: vec![],
                prohibited_operation_kinds: vec![op],
                condition: BarrierCondition::Always,
            }],
        }
    }

    /// The first event in the plain fixture's (single) voice, and a delete of it.
    fn first_event_delete(session: &EditorSession) -> (EventId, OperationKind) {
        let event = session
            .score()
            .voices()
            .find_map(|(_, _, v)| v.events.first().copied())
            .expect("the fixture has an event");
        let delete = OperationKind::DeleteEvent(DeleteEventOp {
            event,
            tuplet_compensation: TupletCompensation::NotInTuplet,
        });
        (event, delete)
    }

    #[test]
    fn a_barrier_matching_edit_is_refused_and_a_non_matching_edit_proceeds() {
        let mut session = open_plain(7);
        let (_, delete) = first_event_delete(&session);
        session.set_active_extensions(vec![extension_prohibiting(
            0xE1,
            OperationKindTag::DeleteEvent,
        )]);

        // The matching edit is refused, naming the declaring extension, with
        // nothing minted (the op log untouched).
        assert_eq!(
            session.apply(delete.clone()),
            Err(EditorError::BarrierProhibited {
                extension: ExtensionRef(0xE1),
                operation: OperationKindTag::DeleteEvent,
            })
        );
        assert!(session.applied_operations().is_empty());

        // An edit of a different operation class proceeds.
        let pitch = last_event_pitch(&session);
        let transpose = OperationKind::Transpose(TransposeOp {
            targets: vec![pitch],
            chromatic_steps: 1,
        });
        session
            .apply(transpose)
            .expect("a non-prohibited class passes the gate");
        assert_eq!(session.applied_operations().len(), 1);
    }

    #[test]
    fn a_region_scoped_barrier_gates_by_the_targets_real_containment() {
        // A barrier scoped to a *different* region does not prohibit deleting
        // an event here — known scopes are evaluated precisely against the
        // target's containment, not conservatively.
        let mut session = open_plain(7);
        let (_, delete) = first_event_delete(&session);
        let elsewhere = RegionId::from_raw(u128::MAX);
        let scoped = |region| ActiveExtension {
            extension: ExtensionRef(0xE2),
            barriers: vec![EditBarrier {
                scope: BarrierScope::Region(region),
                affected_object_kinds: vec![],
                prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
                condition: BarrierCondition::Always,
            }],
        };
        session.set_active_extensions(vec![scoped(elsewhere)]);
        session
            .apply(delete.clone())
            .expect("a barrier over another region does not bind here");

        // The same barrier scoped to the event's own region refuses the edit.
        let mut session = open_plain(7);
        let (_, delete) = first_event_delete(&session);
        let here = session.score().canvas.regions[0].id;
        session.set_active_extensions(vec![scoped(here)]);
        assert_eq!(
            session.apply(delete),
            Err(EditorError::BarrierProhibited {
                extension: ExtensionRef(0xE2),
                operation: OperationKindTag::DeleteEvent,
            })
        );
    }

    #[test]
    fn an_object_kind_narrowed_barrier_matches_only_that_kind() {
        // A barrier protecting only Pitch objects prohibits a transpose but not
        // an event delete, even under the same prohibited class list.
        let mut session = open_plain(7);
        let pitch = last_event_pitch(&session);
        let pitch_kind = ObjectKind::of(&TypedObjectId::Pitch(pitch));
        session.set_active_extensions(vec![ActiveExtension {
            extension: ExtensionRef(0xE3),
            barriers: vec![EditBarrier {
                scope: BarrierScope::WholeScore,
                affected_object_kinds: vec![pitch_kind],
                prohibited_operation_kinds: vec![
                    OperationKindTag::Transpose,
                    OperationKindTag::DeleteEvent,
                ],
                condition: BarrierCondition::Always,
            }],
        }]);
        let transpose = OperationKind::Transpose(TransposeOp {
            targets: vec![pitch],
            chromatic_steps: 1,
        });
        assert!(matches!(
            session.apply(transpose),
            Err(EditorError::BarrierProhibited { .. })
        ));
        let (_, delete) = first_event_delete(&session);
        session
            .apply(delete)
            .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 repeat_authoring_kinds_derive_subjects_and_gate_on_barriers() {
        // The repeat pair (schema-major-2 revision) participates in the
        // barrier gate: a whole-score barrier naming each tag refuses the
        // matching edit before minting, and with no barriers active the
        // create/delete pair applies end to end (subjects derive from the
        // repeat's anchor sites through `repeat_event_refs`).
        let mut session = open_plain(11);
        let events: Vec<epiphany_core::EventId> = session
            .score()
            .voices()
            .flat_map(|(_, _, v)| v.events.clone())
            .collect();
        let (e0, e1) = (events[0], events[1]);
        let rid = epiphany_core::RepeatStructureId::new(epiphany_core::ReplicaId(0xED), 1);
        let create = OperationKind::CreateRepeatStructure(epiphany_ops::CreateRepeatStructureOp {
            repeat: epiphany_ops::valuegen::repeat_structure(rid, e0, e1),
        });
        let delete = OperationKind::DeleteRepeatStructure(epiphany_ops::DeleteRepeatStructureOp {
            repeat: rid,
        });

        for (tag, kind) in [
            (OperationKindTag::CreateRepeatStructure, create.clone()),
            (OperationKindTag::DeleteRepeatStructure, delete.clone()),
        ] {
            session.set_active_extensions(vec![extension_prohibiting(0xE8, tag)]);
            assert_eq!(
                session.apply(kind),
                Err(EditorError::BarrierProhibited {
                    extension: ExtensionRef(0xE8),
                    operation: tag,
                }),
                "a whole-score barrier naming {tag:?} must refuse the edit"
            );
        }
        assert!(session.applied_operations().is_empty());

        session.set_active_extensions(vec![]);
        session
            .apply(create)
            .expect("an unbarred repeat create applies");
        assert!(
            session
                .score()
                .cross_cutting
                .repeats
                .iter()
                .any(|r| r.id == rid),
            "the mint materializes"
        );
        session
            .apply(delete)
            .expect("an unbarred repeat delete applies");
        assert!(
            !session
                .score()
                .cross_cutting
                .repeats
                .iter()
                .any(|r| r.id == rid),
            "the tombstone removes it"
        );
    }

    #[test]
    fn a_region_scoped_barrier_gates_a_region_anchored_repeat() {
        // A repeat anchored ONLY to a region derives that region as its
        // containment (repeat_context walks all anchor objects, not just
        // events) — a region-scoped barrier over it must fire, and one over
        // another region must not. (An event-only walk left region-anchored
        // repeats with a default context, silently bypassing the scope.)
        let scoped = |region| ActiveExtension {
            extension: ExtensionRef(0xE9),
            barriers: vec![EditBarrier {
                scope: BarrierScope::Region(region),
                affected_object_kinds: vec![],
                prohibited_operation_kinds: vec![OperationKindTag::CreateRepeatStructure],
                condition: BarrierCondition::Always,
            }],
        };
        let create_in = |session: &EditorSession| {
            let here = session.score().canvas.regions[0].id;
            let anchor = |edge| epiphany_core::TimeAnchor::Region {
                id: here,
                edge,
                offset: epiphany_core::AnchorOffset::Zero,
            };
            OperationKind::CreateRepeatStructure(epiphany_ops::CreateRepeatStructureOp {
                repeat: epiphany_core::RepeatStructure {
                    id: epiphany_core::RepeatStructureId::new(epiphany_core::ReplicaId(0xEE), 1),
                    start: anchor(epiphany_core::RegionEdge::Start),
                    end: anchor(epiphany_core::RegionEdge::End),
                    kind: epiphany_core::RepeatKind::SimpleRepeat { count: 2 },
                    voltas: Vec::new(),
                },
            })
        };

        let mut session = open_plain(7);
        let elsewhere = RegionId::from_raw(u128::MAX);
        session.set_active_extensions(vec![scoped(elsewhere)]);
        let create = create_in(&session);
        session
            .apply(create)
            .expect("a barrier over another region does not bind here");

        let mut session = open_plain(7);
        let here = session.score().canvas.regions[0].id;
        session.set_active_extensions(vec![scoped(here)]);
        let create = create_in(&session);
        assert_eq!(
            session.apply(create),
            Err(EditorError::BarrierProhibited {
                extension: ExtensionRef(0xE9),
                operation: OperationKindTag::CreateRepeatStructure,
            }),
            "the protected region's own barrier fires for a region-anchored repeat"
        );
    }

    #[test]
    fn an_unsafe_edit_crosses_the_barrier_and_records_the_tombstone_obligation() {
        let mut session = open_plain(7);
        let (_, delete) = first_event_delete(&session);
        session.set_active_extensions(vec![extension_prohibiting(
            0xE4,
            OperationKindTag::DeleteEvent,
        )]);
        assert!(matches!(
            session.apply(delete.clone()),
            Err(EditorError::BarrierProhibited { .. })
        ));

        // The explicit unsafe edit proceeds ...
        let outcome = session.apply_unsafe(delete).expect("the unsafe edit lands");
        assert!(outcome.graph_changed);
        // ... records that the crossed extension's chunks MUST be tombstoned
        // (spec §"Behavior Under Unknown Extensions") ...
        assert!(session
            .extensions_requiring_tombstone()
            .contains(&ExtensionRef(0xE4)));
        // ... and deactivates the extension (its data is gone, so its barriers
        // no longer bind): the next matching edit passes the ordinary gate.
        assert!(session.active_extensions().is_empty());
        let (_, next_delete) = first_event_delete(&session);
        session
            .apply(next_delete)
            .expect("the crossed extension's barriers no longer bind");
    }

    #[test]
    fn an_unsafe_edit_crossing_no_barrier_records_nothing() {
        let mut session = open_plain(7);
        session.set_active_extensions(vec![extension_prohibiting(
            0xE5,
            OperationKindTag::DeleteEvent,
        )]);
        let pitch = last_event_pitch(&session);
        session
            .apply_unsafe(OperationKind::Transpose(TransposeOp {
                targets: vec![pitch],
                chromatic_steps: 1,
            }))
            .expect("an unsafe apply of a non-matching edit is an ordinary apply");
        assert!(session.extensions_requiring_tombstone().is_empty());
        assert_eq!(session.active_extensions().len(), 1);
    }

    #[test]
    fn the_barrier_gate_and_the_advisory_gate_coexist() {
        use epiphany_core::{AnchorOffset, RegionEdge, ReplicaId};
        // The plain fixture with its region's end bound declared at 12 whole
        // units — the shape under which an event spanning 10..14 fails the
        // InsertEvent advisory precondition (Chapter 6 §6.10).
        let mut score = valid_score(7);
        let region_id = score.canvas.regions[0].id;
        score.canvas.regions[0].time_extent.end = epiphany_core::TimeAnchor::Region {
            id: region_id,
            edge: RegionEdge::Start,
            offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(12))),
        };
        let instance = score.canvas.regions[0].staff_instances()[0].id;
        let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id;
        let mut session =
            EditorSession::open(score, Box::new(StubSolver)).expect("the bounded fixture renders");
        let crossing_insert = |duration: i32| {
            OperationKind::InsertEvent(InsertEventOp {
                staff_instance: instance,
                event: epiphany_ops::valuegen::insert_event_value(
                    EventId::new(ReplicaId(50), 999),
                    voice,
                    MusicalPosition(RationalTime::from_int(10)),
                    MusicalDuration(RationalTime::from_int(duration)),
                    &[PitchId::new(ReplicaId(50), 998)],
                ),
            })
        };

        // With no barriers, the advisory gate alone refuses the crossing span.
        assert!(matches!(
            session.apply(crossing_insert(4)),
            Err(EditorError::AdvisoryViolation { .. })
        ));

        // With a matching barrier, the barrier gate fires first (its refusal is
        // the spec's MUST and names the unsafe-edit escape).
        session.set_active_extensions(vec![extension_prohibiting(
            0xE6,
            OperationKindTag::InsertEvent,
        )]);
        assert!(matches!(
            session.apply(crossing_insert(4)),
            Err(EditorError::BarrierProhibited { .. })
        ));

        // The unsafe path crosses the barrier but still enforces the advisory
        // gate — and a refused edit loses no data, so nothing is recorded.
        assert!(matches!(
            session.apply_unsafe(crossing_insert(4)),
            Err(EditorError::AdvisoryViolation { .. })
        ));
        assert!(session.extensions_requiring_tombstone().is_empty());
        assert_eq!(session.active_extensions().len(), 1, "nothing was crossed");

        // A span inside the bound passes the advisory gate; unsafely applying
        // it crosses the barrier and records the obligation.
        let outcome = session
            .apply_unsafe(crossing_insert(2))
            .expect("within the bound, only the barrier stood in the way");
        assert!(outcome.graph_changed);
        assert!(session
            .extensions_requiring_tombstone()
            .contains(&ExtensionRef(0xE6)));
    }

    #[test]
    fn a_transaction_is_gated_per_member_and_has_an_unsafe_sibling() {
        let mut session = open_plain(7);
        let pitch = last_event_pitch(&session);
        let transpose = OperationKind::Transpose(TransposeOp {
            targets: vec![pitch],
            chromatic_steps: 1,
        });
        session.set_active_extensions(vec![extension_prohibiting(
            0xE7,
            OperationKindTag::Transpose,
        )]);

        // A member matching a barrier refuses the whole transaction, unminted.
        assert_eq!(
            session.apply_transaction("sharpen", None, vec![transpose.clone()]),
            Err(EditorError::BarrierProhibited {
                extension: ExtensionRef(0xE7),
                operation: OperationKindTag::Transpose,
            })
        );
        assert!(session.applied_operations().is_empty());

        // The unsafe sibling crosses and records.
        session
            .apply_transaction_unsafe("sharpen", None, vec![transpose])
            .expect("the unsafe transaction lands");
        assert!(session
            .extensions_requiring_tombstone()
            .contains(&ExtensionRef(0xE7)));

        // A score-wide barrier on the descriptor class gates transactions as a
        // whole: DeclareTransaction is a score-level operation.
        let mut session = open_plain(7);
        let pitch = last_event_pitch(&session);
        session.set_active_extensions(vec![extension_prohibiting(
            0xE8,
            OperationKindTag::DeclareTransaction,
        )]);
        assert_eq!(
            session.apply_transaction(
                "sharpen",
                None,
                vec![OperationKind::Transpose(TransposeOp {
                    targets: vec![pitch],
                    chromatic_steps: 1,
                })],
            ),
            Err(EditorError::BarrierProhibited {
                extension: ExtensionRef(0xE8),
                operation: OperationKindTag::DeclareTransaction,
            })
        );
    }

    // ==========================================================================
    // Selection v2: the selection set with an anchor.
    // ==========================================================================

    /// Two glyph click points for two **distinct** pitches (skips a second glyph of
    /// an already-seen pitch, e.g. an accidental) — for multi-member selection
    /// tests that just need "two different objects", not specific ones.
    fn two_notehead_points(session: &EditorSession) -> (Point, Point) {
        let mut seen: Vec<PitchId> = Vec::new();
        let mut points: Vec<Point> = Vec::new();
        for r in &session.hit_test().regions {
            if !r.primitive.is_glyph() {
                continue;
            }
            let TypedObjectId::Pitch(pid) = r.source else {
                continue;
            };
            if seen.contains(&pid) {
                continue;
            }
            let HitShape::Box(b) = r.shape else { continue };
            seen.push(pid);
            points.push(Point::new(
                (b.left.0 + b.right.0) / 2.0,
                (b.bottom.0 + b.top.0) / 2.0,
            ));
            if points.len() == 2 {
                break;
            }
        }
        (points[0], points[1])
    }

    /// The pitch ids of every event in `region_index`'s first staff instance's
    /// first voice, in voice order.
    fn region_pitches(session: &EditorSession, region_index: usize) -> Vec<PitchId> {
        let events = session.score().canvas.regions[region_index].staff_instances()[0].voices[0]
            .events
            .clone();
        events
            .iter()
            .filter_map(|eid| {
                let mut buf: Vec<&IdentifiedPitch> = Vec::new();
                session
                    .score()
                    .events
                    .get(*eid)?
                    .collect_identified_pitches(&mut buf);
                buf.first().map(|ip| ip.id)
            })
            .collect()
    }

    /// A pitch belonging to no tuplet — for tests that need an ordinary, always-
    /// deletable/transposable target alongside a refusal-prone tuplet member.
    fn non_tuplet_pitch(session: &EditorSession) -> PitchId {
        let tuplet_events: std::collections::BTreeSet<EventId> = session
            .score()
            .cross_cutting
            .tuplets
            .iter()
            .flat_map(|t| t.members.iter().copied())
            .collect();
        session
            .score()
            .events
            .iter()
            .filter(|e| !tuplet_events.contains(&e.id()))
            .find_map(|e| {
                let mut buf: Vec<&IdentifiedPitch> = Vec::new();
                e.collect_identified_pitches(&mut buf);
                buf.first().map(|ip| ip.id)
            })
            .expect("a non-tuplet pitch exists in the rich fixture")
    }

    /// Test-only: builds a selection set naming exactly `sources`, in that order
    /// (anchor = the first), bypassing click/drag geometry so multi-member
    /// fixtures for atomicity/ordering/relayout tests don't depend on exact
    /// primitive layout. Each member's `layout_object` is looked up from the
    /// current hit-test map when a region for that source exists there (so
    /// relayout-preservation tests still see a real, trackable id); a source with
    /// no current hit region (e.g. selecting an event directly, when only its
    /// pitch renders a clickable glyph) gets a placeholder — irrelevant to intents
    /// that only ever read `.source` (`delete_selection`, `alter_selection`).
    fn select_sources(session: &mut EditorSession, sources: &[TypedObjectId]) {
        let members: Vec<Selection> = sources
            .iter()
            .map(|&source| {
                let layout_object = session
                    .hit_test()
                    .regions
                    .iter()
                    .find(|r| r.source == source)
                    .map(|r| r.layout_object)
                    .unwrap_or(LayoutObjectId(0));
                Selection {
                    source,
                    layout_object,
                }
            })
            .collect();
        let anchor = members.first().map(|s| s.layout_object);
        session.selection = SelectionSet { members, anchor };
    }

    #[test]
    fn selections_and_anchor_track_a_single_click() {
        let mut session = open_rich(0x5EED);
        assert!(session.selections().is_empty(), "nothing selected at open");
        assert_eq!(session.anchor(), None);
        assert_eq!(session.selection(), None);

        let selection = click_a_notehead(&mut session);
        assert_eq!(session.selections(), &[selection]);
        assert_eq!(session.anchor(), Some(&selection));
        assert_eq!(
            session.selection(),
            Some(selection),
            "selection() falls out as the anchor"
        );

        session.clear_selection();
        assert!(session.selections().is_empty());
        assert_eq!(session.anchor(), None);
    }

    #[test]
    fn toggle_at_builds_a_set_anchor_tracks_last_added_and_wraps_on_removal() {
        let mut session = open_rich(0x5EED);
        let (a, b) = two_notehead_points(&session);

        let sel_a = session.click(a).expect("a click selects a");
        assert_eq!(session.selections(), &[sel_a]);

        let members = session.toggle_at(b).to_vec();
        assert_eq!(members.len(), 2, "b was added");
        let sel_b = members[1];
        assert_eq!(members, vec![sel_a, sel_b]);
        assert_eq!(
            session.anchor(),
            Some(&sel_b),
            "the just-added member is the anchor"
        );

        // Toggling `b` off again removes it; the anchor (b) is gone, so — per the
        // fallback rule — it falls to the member now at b's old position (there is
        // none after it, so it wraps to the first: `a`, the only member left).
        let members = session.toggle_at(b).to_vec();
        assert_eq!(members, vec![sel_a]);
        assert_eq!(session.anchor(), Some(&sel_a));

        // A miss leaves the set unchanged (unlike a plain click, which would clear
        // it) — a modifier click over empty space is not a deselect-all gesture.
        let miss = Point::new(-9999.0, -9999.0);
        assert!(
            session.hit_test().hit(miss).is_empty(),
            "the point truly misses"
        );
        let members = session.toggle_at(miss).to_vec();
        assert_eq!(members, vec![sel_a]);
    }

    #[test]
    fn select_within_preserves_paint_order_and_anchors_first_m4() {
        let mut session = open_rich(0x5EED);
        // A generous rect over the whole rendered fixture.
        let rect = BoundingBox::new(-10_000.0, -10_000.0, 10_000.0, 10_000.0);

        // The expected set: `within`'s own paint order, deduped by score object
        // (one member per `source`, keeping the position of its first occurrence)
        // — exactly the relationship `select_within` is contracted to realize,
        // computed here independently of `select_within`'s own implementation.
        let mut expected: Vec<Selection> = Vec::new();
        for r in session.hit_test().within(rect) {
            // Order-only check below; the representative-id rule has its own
            // dedicated test (`select_within_collapses_a_ledgered_notes…`).
            if !expected.iter().any(|s: &Selection| s.source == r.source) {
                expected.push(Selection {
                    source: r.source,
                    layout_object: r.layout_object,
                });
            }
        }
        assert!(
            expected.len() >= 2,
            "the fixture renders at least two distinct selectable objects"
        );

        let members = session.select_within(rect).to_vec();
        let member_sources: Vec<TypedObjectId> = members.iter().map(|s| s.source).collect();
        let expected_sources: Vec<TypedObjectId> = expected.iter().map(|s| s.source).collect();
        assert_eq!(
            member_sources, expected_sources,
            "select_within must preserve within()'s paint order, one member per source"
        );
        assert_eq!(
            session.anchor().map(|s| s.source),
            expected_sources.first().copied(),
            "the anchor is the first member in paint order"
        );
    }

    #[test]
    fn select_within_collapses_a_ledgered_notes_ledger_lines_to_one_member() {
        let mut score = valid_score_rich(0xF00D);
        // Region B's first event, pushed far above the staff so its notehead needs
        // ledger lines: several primitives (the ledger strokes, synthesized *from*
        // the pitch) that share the notehead's own `Pitch` source.
        let event_id = score.canvas.regions[1].staff_instances()[0].voices[0].events[0];
        let pitch_id = match score.events.get(event_id) {
            Some(Event::Pitched(pe)) => pe.pitches[0].id,
            _ => panic!("region B's first event is a note"),
        };
        if let Some(Event::Pitched(pe)) = score.events.get_mut(event_id) {
            pe.pitches[0].pitch = cmn_pitch(CmnNominal::C, 8);
        }
        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");

        // Confirm the fixture really produced ledger-line strokes for this pitch —
        // otherwise the test would pass vacuously.
        let ledger_count = session
            .hit_test()
            .regions
            .iter()
            .filter(|r| r.source == TypedObjectId::Pitch(pitch_id) && r.synthesis.is_some())
            .count();
        assert!(
            ledger_count > 0,
            "a note this high must render ledger lines"
        );
        let notehead_layout_object = session
            .hit_test()
            .regions
            .iter()
            .find(|r| r.source == TypedObjectId::Pitch(pitch_id) && r.synthesis.is_none())
            .map(|r| r.layout_object)
            .expect("the notehead's own (non-synthesized) glyph region");

        // A rect covering every primitive sourced from this pitch.
        let rect = session
            .hit_test()
            .regions
            .iter()
            .filter(|r| r.source == TypedObjectId::Pitch(pitch_id))
            .map(|r| r.shape.aabb())
            .fold(
                BoundingBox::new(
                    f32::INFINITY,
                    f32::INFINITY,
                    f32::NEG_INFINITY,
                    f32::NEG_INFINITY,
                ),
                |acc, b| {
                    BoundingBox::new(
                        acc.left.0.min(b.left.0),
                        acc.bottom.0.min(b.bottom.0),
                        acc.right.0.max(b.right.0),
                        acc.top.0.max(b.top.0),
                    )
                },
            );

        let members = session.select_within(rect);
        let this_pitch: Vec<&Selection> = members
            .iter()
            .filter(|m| m.source == TypedObjectId::Pitch(pitch_id))
            .collect();
        assert_eq!(
            this_pitch.len(),
            1,
            "the note and its ledger lines collapse to exactly one member"
        );
        assert_eq!(
            this_pitch[0].layout_object, notehead_layout_object,
            "the member tracks the notehead's own layout object, not a ledger's \
             (which can vanish independently of the still-live note)"
        );
    }

    #[test]
    fn batch_delete_selection_removes_every_member_and_only_those_m1() {
        let mut session = open_rich(0x5EED);
        // Four ordinary (non-tuplet) pitches: both notes of region B and both of
        // region C. The triplet's three pitches (region A) are deliberately NOT
        // selected — they must survive untouched.
        let mut targets = region_pitches(&session, 1); // region B
        targets.extend(region_pitches(&session, 2)); // region C
        assert_eq!(targets.len(), 4, "two proportional + two aleatoric notes");
        let triplet_pitches = region_pitches(&session, 0); // region A (the triplet)
        assert_eq!(triplet_pitches.len(), 3);

        let sources: Vec<TypedObjectId> =
            targets.iter().copied().map(TypedObjectId::Pitch).collect();
        select_sources(&mut session, &sources);
        assert_eq!(session.selections().len(), 4);

        let outcome = session
            .delete_selection()
            .expect("the batch delete applies");
        assert!(outcome.graph_changed);
        assert!(session.selections().is_empty(), "the selection is cleared");

        let live = session.score().live_pitch_ids();
        for pid in &targets {
            assert!(
                !live.contains(pid),
                "{pid:?} must be deleted (it was selected)"
            );
        }
        for pid in &triplet_pitches {
            assert!(
                live.contains(pid),
                "{pid:?} must survive — it was not selected"
            );
        }
    }

    #[test]
    fn batch_delete_rolls_back_atomically_on_a_refused_member_m2() {
        let mut session = open_rich(0x5EED);
        // A tuplet member's *event*: `delete_selection` always builds `DeleteEvent`
        // with the plain `NotInTuplet` compensation, which the reducer's own
        // `graph_delete_precondition` refuses for a real tuplet member
        // (`TupletCompensationInvalid`) — the refusal-worthy member the contract
        // names, not a construction-time error.
        let tuplet_event = session.score().cross_cutting.tuplets[0].members[0];
        let ordinary_pitch = non_tuplet_pitch(&session);
        let before = session.score().canonical_bytes();

        select_sources(
            &mut session,
            &[
                TypedObjectId::Pitch(ordinary_pitch),
                TypedObjectId::Event(tuplet_event),
            ],
        );

        let err = session
            .delete_selection()
            .expect_err("the tuplet member's refusal rolls back the whole batch");
        assert_eq!(err, EditorError::RejectedOperation);
        assert_eq!(
            session.score().canonical_bytes(),
            before,
            "atomicity: the ordinary pitch must NOT be deleted just because a \
             sibling member refused"
        );
    }

    #[test]
    fn anchor_falls_to_the_next_surviving_member_across_a_relayout_m3() {
        let mut session = open_rich(0x5EED);
        let pids = region_pitches(&session, 1); // region B, two ordinary pitches
        let (pid0, pid1) = (pids[0], pids[1]);

        select_sources(
            &mut session,
            &[TypedObjectId::Pitch(pid0), TypedObjectId::Pitch(pid1)],
        );
        assert_eq!(
            session.anchor().map(|s| s.source),
            Some(TypedObjectId::Pitch(pid0))
        );

        // A plain single delete of the ANCHOR's pitch (bypassing `delete_selection`,
        // to isolate the relayout/anchor-fallback mechanism from the batch-delete
        // one m1/m2 already cover) — its notehead's layout object is gone
        // afterward (degrades to a rest), while `pid1`'s is untouched.
        let outcome = session
            .apply(OperationKind::DeleteIdentifiedPitch(
                DeleteIdentifiedPitchOp { pitch: pid0 },
            ))
            .expect("the delete applies");

        assert!(
            !outcome.selection_preserved,
            "the anchor's own object did not survive"
        );
        assert_eq!(
            session.selections().len(),
            1,
            "pid0's member dropped; pid1's survives"
        );
        assert_eq!(session.selections()[0].source, TypedObjectId::Pitch(pid1));
        assert_eq!(
            session.anchor().map(|s| s.source),
            Some(TypedObjectId::Pitch(pid1)),
            "the anchor falls to the next surviving member"
        );
    }

    #[test]
    fn alter_selection_single_member_mints_the_same_op_as_before_the_set_model() {
        let mut session = open_rich(0x5EED);
        let selection = click_a_notehead(&mut session);
        let TypedObjectId::Pitch(pid) = selection.source else {
            panic!("the clicked notehead selects a pitch");
        };

        session.alter_selection(1).expect("the sharpen applies");

        assert_eq!(
            session.applied_operations().len(),
            1,
            "no DeclareTransaction wrapper for one member"
        );
        let OperationPayload::Primitive(OperationKind::TransposeInterval(op)) =
            &session.applied_operations()[0].payload
        else {
            panic!("a TransposeInterval was minted");
        };
        assert_eq!(op.targets, [pid].into_iter().collect());
        assert_eq!(
            op.interval,
            TranspositionInterval {
                diatonic_steps: 0,
                chromatic_steps: 1,
            }
        );
    }

    #[test]
    fn alter_selection_batch_transposes_every_pitch_member_in_one_native_op() {
        let mut session = open_rich(0x5EED);
        let pids = region_pitches(&session, 1); // region B, two ordinary pitches
        let before: Vec<Pitch> = pids
            .iter()
            .map(|p| session.current_pitch(*p).expect("live"))
            .collect();

        select_sources(
            &mut session,
            &pids
                .iter()
                .copied()
                .map(TypedObjectId::Pitch)
                .collect::<Vec<_>>(),
        );
        let outcome = session
            .alter_selection(1)
            .expect("the batch sharpen applies");
        assert!(outcome.graph_changed);

        // One native multi-target op, no `DeclareTransaction` wrapper — the design
        // recorded in `DECISIONS.md`: `TransposeIntervalOp` is already atomic over
        // a target *set*, so batching through it needs no transaction wrapper at
        // any set size.
        assert_eq!(session.applied_operations().len(), 1);
        let OperationPayload::Primitive(OperationKind::TransposeInterval(op)) =
            &session.applied_operations()[0].payload
        else {
            panic!("a TransposeInterval was minted");
        };
        let targets: std::collections::BTreeSet<PitchId> = pids.iter().copied().collect();
        assert_eq!(op.targets, targets);

        for (pid, before) in pids.iter().zip(&before) {
            let after = session.current_pitch(*pid).expect("still live");
            assert_ne!(&after, before, "{pid:?} was transposed");
        }
    }

    #[test]
    fn alter_selection_batch_is_atomic_on_an_untransposable_member() {
        let mut score = valid_score_rich(0x5EED);
        // Pin region C's first pitch to an absolute frequency — untransposable
        // under `req:pitch:transposition` (`TransposeRefusal::AcousticPinned`).
        let event_id = score.canvas.regions[2].staff_instances()[0].voices[0].events[0];
        let pinned_pitch = match score.events.get(event_id) {
            Some(Event::Pitched(pe)) => pe.pitches[0].id,
            _ => panic!("a note"),
        };
        if let Some(Event::Pitched(pe)) = score.events.get_mut(event_id) {
            pe.pitches[0].pitch.acoustic.realization = AcousticRealization::absolute_hz(440.0)
                .expect("440 Hz is a valid absolute realization");
        }
        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");

        let ordinary_pitch = region_pitches(&session, 1)[0]; // region B, untouched
        let before = session.current_pitch(ordinary_pitch).expect("live");

        select_sources(
            &mut session,
            &[
                TypedObjectId::Pitch(ordinary_pitch),
                TypedObjectId::Pitch(pinned_pitch),
            ],
        );
        let outcome = session
            .alter_selection(1)
            .expect("a clean refusal, not an Err — matching a single pinned selection");
        assert!(
            !outcome.graph_changed,
            "the pinned pitch refuses the WHOLE op — nothing moves"
        );
        let after = session.current_pitch(ordinary_pitch).expect("still live");
        assert_eq!(
            after, before,
            "the ordinary pitch must NOT move just because a sibling target refused"
        );
    }

    // --- Fragment (copy/paste) tests: Ruling E, `spec/PLAN_EDITOR_APP.md` §W4a. ---

    /// A tiny hand-built plain score: one staff/instrument, one metric region
    /// on one staff instance, one voice with exactly `count` quarter-note
    /// events at onsets `0/4, 1/4, …`. `valid_score`'s own event count is
    /// seed-random (2..=4, `generators.rs`); fragment tests need a guaranteed
    /// count to select a strict sub-range from, so this mirrors `valid_score`'s
    /// shape with a fixed one instead.
    fn small_metric_score(count: i64) -> Score {
        use epiphany_core::{
            Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region,
            RegionContent, Staff, StaffBasedContent, StaffExtent, StaffLineConfiguration,
            TimeAnchor, TimeExtent, Voice,
        };
        let replica = ReplicaId(0x4321);
        let mut idc = IdentityContext::new(replica);
        let staff_id: StaffId = idc.mint();
        let instrument: InstrumentId = idc.mint();
        let region_id: RegionId = idc.mint();
        let instance_id: StaffInstanceId = idc.mint();
        let voice_id: VoiceId = idc.mint();

        let mut arena = EventArena::new();
        let mut voice = Voice::user(voice_id);
        for index in 0..count {
            let eid: EventId = idc.mint();
            let pid: PitchId = idc.mint();
            arena
                .insert(Event::Pitched(PitchedEvent {
                    id: eid,
                    voice: voice_id,
                    position: EventPosition::Musical(MusicalPosition(
                        RationalTime::new(index, 4).unwrap(),
                    )),
                    duration: EventDuration::Musical(MusicalDuration(
                        RationalTime::new(1, 4).unwrap(),
                    )),
                    pitches: vec![IdentifiedPitch {
                        id: pid,
                        // On the staff (treble clef's second line), so it never
                        // needs a ledger line — geometry tests below select over
                        // rendered rects and must not catch a neighbor's ledger.
                        pitch: cmn_pitch(CmnNominal::G, 4),
                    }],
                    articulations: vec![],
                    dynamic: None,
                    ornaments: vec![],
                    stem: StemConfiguration,
                    grace: None,
                }))
                .expect("fresh event id");
            voice.events.push(eid);
        }
        let mut instance = StaffInstance::new(instance_id, staff_id);
        instance.voices.push(voice);

        let region = Region {
            id: region_id,
            time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
            content: RegionContent::StaffBased(StaffBasedContent {
                staff_instances: vec![instance],
                ..Default::default()
            }),
            time_extent: TimeExtent {
                start: TimeAnchor::WallClock {
                    time: WallClockTime(0),
                },
                end: TimeAnchor::WallClock {
                    time: WallClockTime(1_000_000),
                },
            },
            staff_extent: StaffExtent {
                staves: vec![staff_id],
            },
            local_tempo_map: None,
            permits_spanning_slurs: false,
        };

        let mut score = Score::empty(idc.clone());
        score.identity = idc;
        score.staves = vec![Staff {
            id: staff_id,
            name: String::from("staff"),
            abbreviation: None,
            instrument,
            default_staff_lines: StaffLineConfiguration::default(),
            group: None,
            default_clef: Clef::treble(),
        }];
        score.instruments = vec![Instrument::new(instrument, String::from("instrument"))];
        score.events = arena;
        score.canvas = Canvas {
            regions: vec![region],
            ..Default::default()
        };
        score
    }

    /// The bounding box covering every rendered glyph sourced from any of
    /// `pitches` — the geometry `select_within` is exercised against (same
    /// technique as `select_within_collapses_a_ledgered_notes_ledger_lines_to_one_member`).
    fn rect_covering_pitches(session: &EditorSession, pitches: &[PitchId]) -> BoundingBox {
        session
            .hit_test()
            .regions
            .iter()
            .filter(|r| matches!(r.source, TypedObjectId::Pitch(p) if pitches.contains(&p)))
            .map(|r| r.shape.aabb())
            .fold(
                BoundingBox::new(
                    f32::INFINITY,
                    f32::INFINITY,
                    f32::NEG_INFINITY,
                    f32::NEG_INFINITY,
                ),
                |acc, b| {
                    BoundingBox::new(
                        acc.left.0.min(b.left.0),
                        acc.bottom.0.min(b.bottom.0),
                        acc.right.0.max(b.right.0),
                        acc.top.0.max(b.top.0),
                    )
                },
            )
    }

    /// The id of `eid`'s first pitch.
    fn first_pitch_of(session: &EditorSession, eid: EventId) -> PitchId {
        first_pitch_id_in_score(session.score(), eid)
    }

    /// The id of `eid`'s first pitch, read directly off a `Score` — the
    /// pre-`EditorSession::open` sibling of [`first_pitch_of`], for fixtures
    /// that need a source `PitchId` before a session exists (e.g. to build
    /// an explicit tie `pitch_pairing` on the raw score).
    fn first_pitch_id_in_score(score: &Score, eid: EventId) -> PitchId {
        let mut buf: Vec<&IdentifiedPitch> = Vec::new();
        score
            .events
            .get(eid)
            .expect("live event")
            .collect_identified_pitches(&mut buf);
        buf.first().expect("a pitch").id
    }

    /// Round-trip: `select_within` over rendered geometry selects two
    /// adjacent notes (one carrying an authored spelling override), copy,
    /// paste at a position far past the fixture's own content. The pasted
    /// events' VALUES — pitches (including the carried spelling override via
    /// a fresh `RespellPitch`), durations, and their onset *relative to each
    /// other* — equal the source's, and their ids are fresh.
    ///
    /// Mutation f1 (drop the `RespellPitch` carry, either the capture in
    /// `EditorSession::fragment_event` or the mint in
    /// `fragment_event_to_op_event`): the spelling-fidelity assertion below
    /// dies.
    #[test]
    fn copy_paste_round_trip_preserves_values_with_fresh_ids() {
        let mut session =
            EditorSession::open(small_metric_score(4), Box::new(StubSolver)).expect("renders");
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        assert_eq!(before.len(), 4);

        // Pin the first note's spelling with an explicit user override — copy
        // must carry it via a fresh `RespellPitch`, not the source pitch id.
        let source_pitch = first_pitch_of(&session, before[0].0);
        let override_spelling = PitchSpelling::cmn(CmnNominal::D, 4);
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: source_pitch,
                spelling: override_spelling.clone(),
            }))
            .expect("the respell applies");

        // Select the first two notes over their rendered geometry. The rect
        // is built tightly around those two notes' own glyphs, but a real
        // rubber-band `select_within` over a staff also sweeps in incidental
        // geometry (the staff lines it visually crosses, each note's own
        // stem) — `copy_selection` skips anything that is not a pitch/event
        // resolving to a copyable note (see its doc comment); only the two
        // targeted notes end up in the fragment, asserted below via the
        // fragment's own event count rather than the raw selection's.
        let pitch0 = first_pitch_of(&session, before[0].0);
        let pitch1 = first_pitch_of(&session, before[1].0);
        let rect = rect_covering_pitches(&session, &[pitch0, pitch1]);
        session.select_within(rect);

        let copy = session.copy_selection().expect("the selection copies");
        assert!(
            copy.dropped.is_empty(),
            "nothing spans the selection boundary in this fixture"
        );
        let decoded = fragment::decode(&copy.fragment).expect("a well-formed fragment");
        let fragment_event_count: usize = decoded.voices.iter().map(|v| v.events.len()).sum();
        assert_eq!(
            fragment_event_count, 2,
            "exactly the two targeted notes made it into the fragment"
        );
        assert_eq!(
            copy.events_copied, 2,
            "events_copied agrees with the fragment's own event count"
        );

        // Paste far past the fixture's own content (its four notes span
        // [0, 1)) — a clearly distinct destination.
        let far = MusicalPosition(RationalTime::new(50, 4).unwrap());
        let (_, _, origin_y) = region_staff_line(&session, region);
        let at = click_for_position(&session, region, &far, origin_y + 1.0);
        let source_ids: BTreeSet<EventId> = before.iter().map(|(id, _, _)| *id).collect();

        let outcome = session
            .paste_at(at, &grid(1, 4), &copy.fragment)
            .expect("the paste applies");
        assert_eq!(outcome.events_inserted, 2);

        let mut pasted: Vec<(EventId, MusicalPosition, MusicalDuration)> =
            voice_events(&session, voice)
                .into_iter()
                .filter(|(id, _, _)| !source_ids.contains(id))
                .collect();
        pasted.sort_by(|a, b| a.1.cmp(&b.1));
        assert_eq!(pasted.len(), 2, "two fresh events appeared");
        for (id, _, _) in &pasted {
            assert!(
                !source_ids.contains(id),
                "pasted ids are fresh, disjoint from the source"
            );
        }

        // Onsets: the first pasted note lands exactly at the target, and the
        // gap between the two pasted notes equals the source gap.
        assert_eq!(
            pasted[0].1, far,
            "the first pasted note lands at the target"
        );
        let src_gap = before[1].1 .0.sub(&before[0].1 .0);
        let dst_gap = pasted[1].1 .0.sub(&pasted[0].1 .0);
        assert_eq!(src_gap, dst_gap, "the relative onset is preserved");

        // Durations preserved.
        assert_eq!(pasted[0].2, before[0].2);
        assert_eq!(pasted[1].2, before[1].2);

        // Pitch values preserved (raw nominal/octave), and the authored
        // spelling override on the first note carried onto its fresh pitch.
        let dst_pitch0 = first_pitch_of(&session, pasted[0].0);
        let dst_pitch1 = first_pitch_of(&session, pasted[1].0);
        assert_ne!(dst_pitch0, source_pitch, "the pasted pitch id is fresh");
        assert_eq!(
            session.current_pitch(dst_pitch0),
            session.current_pitch(pitch0),
            "the first pitch's value is preserved"
        );
        assert_eq!(
            session.current_pitch(dst_pitch1),
            session.current_pitch(pitch1),
            "the second pitch's value is preserved"
        );
        assert_eq!(
            authored_spelling(session.score(), dst_pitch0),
            Some(override_spelling),
            "the authored spelling override carried via a fresh RespellPitch"
        );
        assert_eq!(
            authored_spelling(session.score(), dst_pitch1),
            None,
            "the second note never had an override — none is invented"
        );
    }

    /// r1 (T2 W4b, closing the W4a slur/tie-replay follow-up): copying a
    /// range containing a **whole** slur, then pasting far away, mints a
    /// brand-new slur at the destination — a fresh [`SlurId`], disjoint from
    /// the source's, spanning **exactly** the two freshly-pasted events (not
    /// the source's own event ids).
    ///
    /// Mutation r1 (skip the slur re-mint loop in `paste_document`): the
    /// destination gains no new slur at all (`slurs_inserted` stays 0 and no
    /// second slur appears in `cross_cutting.slurs`) — this test dies on the
    /// `slurs_inserted`/slur-presence assertions below. Confirmed live, then
    /// reverted.
    #[test]
    fn paste_replays_a_contained_slur_as_a_fresh_cross_cutting_structure() {
        use epiphany_core::{RegionContent, Slur, SlurId, SlurKind, SpanStyle};

        let mut score = small_metric_score(4);
        let events: Vec<EventId> = {
            let RegionContent::StaffBased(content) = &score.canvas.regions[0].content else {
                panic!("region 0 is staff-based");
            };
            content.staff_instances[0].voices[0].events.clone()
        };
        let source_slur_id: SlurId = score.identity.mint();
        score.cross_cutting.slurs.push(Slur {
            id: source_slur_id,
            start_event: events[0],
            end_event: events[1],
            kind: SlurKind::Phrase,
            curvature_override: None,
            style: SpanStyle::default(),
        });

        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let source_ids: BTreeSet<EventId> = before.iter().map(|(id, _, _)| *id).collect();

        let pitch0 = first_pitch_of(&session, before[0].0);
        let pitch1 = first_pitch_of(&session, before[1].0);
        let rect = rect_covering_pitches(&session, &[pitch0, pitch1]);
        session.select_within(rect);
        let copy = session.copy_selection().expect("the selection copies");
        assert!(copy.dropped.is_empty(), "the slur is fully inside the copy");
        let decoded = fragment::decode(&copy.fragment).expect("well-formed");
        assert_eq!(decoded.slurs.len(), 1, "the slur made it into the fragment");

        let far = MusicalPosition(RationalTime::new(50, 4).unwrap());
        let (_, _, origin_y) = region_staff_line(&session, region);
        let at = click_for_position(&session, region, &far, origin_y + 1.0);
        let outcome = session
            .paste_at(at, &grid(1, 4), &copy.fragment)
            .expect("the paste applies");
        assert_eq!(outcome.events_inserted, 2);
        assert_eq!(outcome.slurs_inserted, 1, "one slur re-minted");
        assert_eq!(outcome.ties_inserted, 0);

        let mut pasted: Vec<(EventId, MusicalPosition, MusicalDuration)> =
            voice_events(&session, voice)
                .into_iter()
                .filter(|(id, _, _)| !source_ids.contains(id))
                .collect();
        pasted.sort_by(|a, b| a.1.cmp(&b.1));
        assert_eq!(pasted.len(), 2, "two fresh events appeared");

        assert_eq!(
            session.score().cross_cutting.slurs.len(),
            2,
            "the source slur is untouched and a new one appeared"
        );
        let new_slur = session
            .score()
            .cross_cutting
            .slurs
            .iter()
            .find(|s| s.id != source_slur_id)
            .expect("a new slur exists");
        assert_ne!(
            new_slur.id, source_slur_id,
            "the pasted slur's id is fresh, disjoint from the source's"
        );
        assert_eq!(
            new_slur.start_event, pasted[0].0,
            "the new slur starts at the first pasted event"
        );
        assert_eq!(
            new_slur.end_event, pasted[1].0,
            "the new slur ends at the second pasted event"
        );
        assert_eq!(
            new_slur.kind,
            SlurKind::Phrase,
            "the slur kind carries over"
        );
        assert_eq!(new_slur.curvature_override, None);
        assert_eq!(new_slur.style, SpanStyle::default());
    }

    /// r2 (T2 W4b): the tie equivalent of r1. A tie fully inside the copied
    /// range replays as a fresh [`TieId`] at the destination, spanning
    /// exactly the two pasted events, with `pitch_pairing: None` — the
    /// fragment never carries the source's explicit pairing (Ruling E: no
    /// object ids), and this is not a structural block: the reducer accepts
    /// `pitch_pairing: None` unconditionally (see `DECISIONS.md`). The
    /// source tie's own explicit pairing is deliberately set here so the
    /// test also proves the drop is real, not merely untested.
    ///
    /// Mutation r2 (skip the tie re-mint loop in `paste_document`): dies on
    /// the `ties_inserted`/tie-presence assertions below. Confirmed live,
    /// then reverted.
    #[test]
    fn paste_replays_a_contained_tie_with_the_default_pairing() {
        use epiphany_core::{RegionContent, SpanStyle, Tie, TieClass, TieId};

        let mut score = small_metric_score(4);
        let events: Vec<EventId> = {
            let RegionContent::StaffBased(content) = &score.canvas.regions[0].content else {
                panic!("region 0 is staff-based");
            };
            content.staff_instances[0].voices[0].events.clone()
        };
        let source_pitch0 = first_pitch_id_in_score(&score, events[0]);
        let source_pitch1 = first_pitch_id_in_score(&score, events[1]);
        let source_tie_id: TieId = score.identity.mint();
        score.cross_cutting.ties.push(Tie {
            id: source_tie_id,
            start_event: events[0],
            end_event: events[1],
            // An explicit source pairing — proves the fragment really drops
            // it (Ruling E), not merely that this test never exercised one.
            pitch_pairing: Some(vec![(source_pitch0, source_pitch1)]),
            class: TieClass::Standard,
            style: SpanStyle::default(),
        });

        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let source_ids: BTreeSet<EventId> = before.iter().map(|(id, _, _)| *id).collect();

        let pitch0 = first_pitch_of(&session, before[0].0);
        let pitch1 = first_pitch_of(&session, before[1].0);
        let rect = rect_covering_pitches(&session, &[pitch0, pitch1]);
        session.select_within(rect);
        let copy = session.copy_selection().expect("the selection copies");
        assert!(copy.dropped.is_empty(), "the tie is fully inside the copy");
        let decoded = fragment::decode(&copy.fragment).expect("well-formed");
        assert_eq!(decoded.ties.len(), 1, "the tie made it into the fragment");

        let far = MusicalPosition(RationalTime::new(50, 4).unwrap());
        let (_, _, origin_y) = region_staff_line(&session, region);
        let at = click_for_position(&session, region, &far, origin_y + 1.0);
        let outcome = session
            .paste_at(at, &grid(1, 4), &copy.fragment)
            .expect("the paste applies");
        assert_eq!(outcome.events_inserted, 2);
        assert_eq!(outcome.slurs_inserted, 0);
        assert_eq!(outcome.ties_inserted, 1, "one tie re-minted");

        let mut pasted: Vec<(EventId, MusicalPosition, MusicalDuration)> =
            voice_events(&session, voice)
                .into_iter()
                .filter(|(id, _, _)| !source_ids.contains(id))
                .collect();
        pasted.sort_by(|a, b| a.1.cmp(&b.1));
        assert_eq!(pasted.len(), 2, "two fresh events appeared");

        assert_eq!(
            session.score().cross_cutting.ties.len(),
            2,
            "the source tie is untouched and a new one appeared"
        );
        let new_tie = session
            .score()
            .cross_cutting
            .ties
            .iter()
            .find(|t| t.id != source_tie_id)
            .expect("a new tie exists");
        assert_ne!(
            new_tie.id, source_tie_id,
            "the pasted tie's id is fresh, disjoint from the source's"
        );
        assert_eq!(new_tie.start_event, pasted[0].0);
        assert_eq!(new_tie.end_event, pasted[1].0);
        assert_eq!(
            new_tie.pitch_pairing, None,
            "the source's explicit pairing is not carried; the default applies"
        );
        assert_eq!(
            new_tie.class,
            TieClass::Standard,
            "the tie class carries over"
        );
        assert_eq!(new_tie.style, SpanStyle::default());
    }

    /// Partial tuplet refuses: a selection covering two of the rich fixture's
    /// three triplet members (never all three) makes `copy_selection` return
    /// the closure-v1 refusal, not a fragment.
    ///
    /// Mutation f2 (remove the tuplet-coverage check in `copy_selection`):
    /// this test dies (the copy would succeed instead of refusing).
    #[test]
    fn copy_selection_refuses_a_partially_selected_tuplet() {
        let session = open_rich(0x5EED);
        let tuplet = session.score().cross_cutting.tuplets[0].clone();
        assert_eq!(
            tuplet.members.len(),
            3,
            "the rich fixture's triplet has three members"
        );
        let pitches = region_pitches(&session, 0); // region A, the triplet
        assert_eq!(pitches.len(), 3);

        let mut session = session;
        select_sources(
            &mut session,
            &[
                TypedObjectId::Pitch(pitches[0]),
                TypedObjectId::Pitch(pitches[1]),
            ],
        );
        assert_eq!(
            session.copy_selection(),
            Err(EditorError::PartialTupletSelection { tuplet: tuplet.id })
        );
    }

    /// Paste refusal: pasting a one-note fragment onto a destination whose
    /// make-room hits a nested tuplet (the same fixture shape as
    /// `insert_note_at_over_a_nested_tuplet_is_refused`) errs, and the
    /// destination's `canonical_bytes` are byte-identical to before the call
    /// — `paste_document` builds its whole op list *before* ever calling
    /// `apply`/`apply_transaction`, so a refusal discovered while building
    /// it (as here) can never leave a partial mutation behind, regardless of
    /// how the final application is shaped. `paste_atomicity_rolls_back_
    /// mid_transaction_on_a_second_lanes_refusal` (below) is the test that
    /// isolates the transaction-vs-individual-application question this
    /// scenario cannot (mutation f3 lives there).
    #[test]
    fn paste_refuses_cleanly_on_a_nested_tuplet_and_changes_nothing() {
        // A one-note fragment, copied from a plain, tuplet-free fixture.
        let mut source =
            EditorSession::open(small_metric_score(1), Box::new(StubSolver)).expect("renders");
        let region = a_clean_metric_region(&source);
        let voice = primary_voice(&source, region);
        let events = voice_events(&source, voice);
        let pid = first_pitch_of(&source, events[0].0);
        let rect = rect_covering_pitches(&source, &[pid]);
        source.select_within(rect);
        let copy = source.copy_selection().expect("copies");

        // A nested tuplet on the destination — the flat-cascade make-room
        // rule refuses rather than misstate its ratio arithmetic (mirrors
        // `insert_note_at_over_a_nested_tuplet_is_refused`).
        use epiphany_core::generators::valid_score_rich;
        use epiphany_core::{Tuplet, TupletRatio};
        let mut score = valid_score_rich(0x5EED);
        let triplet_id = score.cross_cutting.tuplets[0].id;
        let replica = score.identity.replica_id;
        score.cross_cutting.tuplets.push(Tuplet {
            id: TupletId::new(replica, 7_000_002),
            ratio: TupletRatio::new(3, 2).unwrap(),
            members: vec![],
            parent: Some(triplet_id),
            required_total: MusicalDuration(RationalTime::new(1, 8).unwrap()),
        });
        let mut dest = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let region = a_region_with(&dest, true);
        let voice = primary_voice(&dest, region);
        let (_, pos, dur) = voice_events(&dest, voice).into_iter().next().unwrap();
        let (_, _, origin_y) = region_staff_line(&dest, region);
        let at = click_for_position(&dest, region, &pos, origin_y + 1.0);
        let before = dest.score().canonical_bytes();

        let err = dest
            .paste_at(at, &GridResolution { step: dur }, &copy.fragment)
            .expect_err("overlapping the nested tuplet refuses");
        assert_eq!(err, EditorError::OverlapsTuplet);
        assert_eq!(
            dest.score().canonical_bytes(),
            before,
            "atomic: nothing changed"
        );
    }

    /// A one-staff, one-region score whose single staff instance has exactly
    /// two voices, each with one quarter-note event at onset 0 — the source
    /// for a two-lane fragment (`copy_selection` groups by voice, so
    /// selecting one note from each voice yields fragment lane 0 and lane 1).
    fn two_voice_one_note_each_score() -> Score {
        use epiphany_core::{
            Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region,
            RegionContent, Staff, StaffBasedContent, StaffExtent, StaffLineConfiguration,
            TimeAnchor, TimeExtent, Voice,
        };
        let replica = ReplicaId(0x8888);
        let mut idc = IdentityContext::new(replica);
        let staff_id: StaffId = idc.mint();
        let instrument: InstrumentId = idc.mint();
        let region_id: RegionId = idc.mint();
        let instance_id: StaffInstanceId = idc.mint();

        let mut arena = EventArena::new();
        let mut instance = StaffInstance::new(instance_id, staff_id);
        for _ in 0..2 {
            let voice_id: VoiceId = idc.mint();
            let mut voice = Voice::user(voice_id);
            let eid: EventId = idc.mint();
            let pid: PitchId = idc.mint();
            arena
                .insert(Event::Pitched(PitchedEvent {
                    id: eid,
                    voice: voice_id,
                    position: EventPosition::Musical(MusicalPosition(
                        RationalTime::new(0, 4).unwrap(),
                    )),
                    duration: EventDuration::Musical(MusicalDuration(
                        RationalTime::new(1, 4).unwrap(),
                    )),
                    pitches: vec![IdentifiedPitch {
                        id: pid,
                        pitch: cmn_pitch(CmnNominal::G, 4),
                    }],
                    articulations: vec![],
                    dynamic: None,
                    ornaments: vec![],
                    stem: StemConfiguration,
                    grace: None,
                }))
                .expect("fresh event id");
            voice.events.push(eid);
            instance.voices.push(voice);
        }

        let region = Region {
            id: region_id,
            time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
            content: RegionContent::StaffBased(StaffBasedContent {
                staff_instances: vec![instance],
                ..Default::default()
            }),
            time_extent: TimeExtent {
                start: TimeAnchor::WallClock {
                    time: WallClockTime(0),
                },
                end: TimeAnchor::WallClock {
                    time: WallClockTime(1_000_000),
                },
            },
            staff_extent: StaffExtent {
                staves: vec![staff_id],
            },
            local_tempo_map: None,
            permits_spanning_slurs: false,
        };

        let mut score = Score::empty(idc.clone());
        score.identity = idc;
        score.staves = vec![Staff {
            id: staff_id,
            name: String::from("staff"),
            abbreviation: None,
            instrument,
            default_staff_lines: StaffLineConfiguration::default(),
            group: None,
            default_clef: Clef::treble(),
        }];
        score.instruments = vec![Instrument::new(instrument, String::from("instrument"))];
        score.events = arena;
        score.canvas = Canvas {
            regions: vec![region],
            ..Default::default()
        };
        score
    }

    /// r3's source fixture (T2 W4b): a one-staff, one-region score whose
    /// staff instance has two voices — voice 0 carries **two** quarter notes
    /// (onset 0 and 1/4) joined by a slur, voice 1 carries one quarter note
    /// at onset 0. Copying all three events yields a two-lane fragment whose
    /// lane 0 (two events + the slur) is exactly [`two_voice_one_note_each_score`]'s
    /// lane 0 with a slur added — the r3 atomicity test's proof that a
    /// cross-cutting structure riding along a multi-lane paste does not
    /// change the all-or-nothing guarantee.
    fn two_voice_first_voice_has_two_notes_with_a_slur_second_voice_one_note_score() -> Score {
        use epiphany_core::{
            Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region,
            RegionContent, Slur, SlurKind, SpanStyle, Staff, StaffBasedContent, StaffExtent,
            StaffLineConfiguration, TimeAnchor, TimeExtent, Voice,
        };
        let replica = ReplicaId(0x7777);
        let mut idc = IdentityContext::new(replica);
        let staff_id: StaffId = idc.mint();
        let instrument: InstrumentId = idc.mint();
        let region_id: RegionId = idc.mint();
        let instance_id: StaffInstanceId = idc.mint();

        let mut arena = EventArena::new();
        let mut instance = StaffInstance::new(instance_id, staff_id);

        // Voice 0: two notes, slurred.
        let voice0_id: VoiceId = idc.mint();
        let mut voice0 = Voice::user(voice0_id);
        let mut voice0_events = Vec::new();
        for index in 0..2i64 {
            let eid: EventId = idc.mint();
            let pid: PitchId = idc.mint();
            arena
                .insert(Event::Pitched(PitchedEvent {
                    id: eid,
                    voice: voice0_id,
                    position: EventPosition::Musical(MusicalPosition(
                        RationalTime::new(index, 4).unwrap(),
                    )),
                    duration: EventDuration::Musical(MusicalDuration(
                        RationalTime::new(1, 4).unwrap(),
                    )),
                    pitches: vec![IdentifiedPitch {
                        id: pid,
                        pitch: cmn_pitch(CmnNominal::G, 4),
                    }],
                    articulations: vec![],
                    dynamic: None,
                    ornaments: vec![],
                    stem: StemConfiguration,
                    grace: None,
                }))
                .expect("fresh event id");
            voice0.events.push(eid);
            voice0_events.push(eid);
        }
        instance.voices.push(voice0);

        // Voice 1: one note.
        let voice1_id: VoiceId = idc.mint();
        let mut voice1 = Voice::user(voice1_id);
        let eid: EventId = idc.mint();
        let pid: PitchId = idc.mint();
        arena
            .insert(Event::Pitched(PitchedEvent {
                id: eid,
                voice: voice1_id,
                position: EventPosition::Musical(MusicalPosition(RationalTime::new(0, 4).unwrap())),
                duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
                pitches: vec![IdentifiedPitch {
                    id: pid,
                    pitch: cmn_pitch(CmnNominal::G, 4),
                }],
                articulations: vec![],
                dynamic: None,
                ornaments: vec![],
                stem: StemConfiguration,
                grace: None,
            }))
            .expect("fresh event id");
        voice1.events.push(eid);
        instance.voices.push(voice1);

        let region = Region {
            id: region_id,
            time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
            content: RegionContent::StaffBased(StaffBasedContent {
                staff_instances: vec![instance],
                ..Default::default()
            }),
            time_extent: TimeExtent {
                start: TimeAnchor::WallClock {
                    time: WallClockTime(0),
                },
                end: TimeAnchor::WallClock {
                    time: WallClockTime(1_000_000),
                },
            },
            staff_extent: StaffExtent {
                staves: vec![staff_id],
            },
            local_tempo_map: None,
            permits_spanning_slurs: false,
        };

        let mut score = Score::empty(idc.clone());
        score.identity = idc;
        score.staves = vec![Staff {
            id: staff_id,
            name: String::from("staff"),
            abbreviation: None,
            instrument,
            default_staff_lines: StaffLineConfiguration::default(),
            group: None,
            default_clef: Clef::treble(),
        }];
        score.instruments = vec![Instrument::new(instrument, String::from("instrument"))];
        score.events = arena;
        score.canvas = Canvas {
            regions: vec![region],
            ..Default::default()
        };
        let slur_id: SlurId = score.identity.mint();
        score.cross_cutting.slurs.push(Slur {
            id: slur_id,
            start_event: voice0_events[0],
            end_event: voice0_events[1],
            kind: SlurKind::Legato,
            curvature_override: None,
            style: SpanStyle::default(),
        });
        score
    }

    /// A destination fixture for the two-lane paste-atomicity test: one
    /// staff instance with two voices — voice 0 is empty (a paste there
    /// makes room trivially, over nothing), voice 1 hosts a three-member
    /// flat triplet with a nested child tuplet pushed onto it (the same
    /// shape `insert_note_at_over_a_nested_tuplet_is_refused` uses), whose
    /// flat-cascade make-room rule refuses rather than misstate a nested
    /// tuplet's ratio arithmetic.
    fn two_voice_second_voice_has_a_nested_tuplet() -> Score {
        use epiphany_core::{
            Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region,
            RegionContent, Staff, StaffBasedContent, StaffExtent, StaffLineConfiguration,
            TimeAnchor, TimeExtent, Tuplet, TupletRatio, Voice,
        };
        let replica = ReplicaId(0x9999);
        let mut idc = IdentityContext::new(replica);
        let staff_id: StaffId = idc.mint();
        let instrument: InstrumentId = idc.mint();
        let region_id: RegionId = idc.mint();
        let instance_id: StaffInstanceId = idc.mint();

        let voice0_id: VoiceId = idc.mint();
        let voice0 = Voice::user(voice0_id);

        let voice1_id: VoiceId = idc.mint();
        let mut voice1 = Voice::user(voice1_id);
        let mut arena = EventArena::new();
        let mut triplet_members = Vec::new();
        for k in 0..3i64 {
            let eid: EventId = idc.mint();
            let pid: PitchId = idc.mint();
            arena
                .insert(Event::Pitched(PitchedEvent {
                    id: eid,
                    voice: voice1_id,
                    position: EventPosition::Musical(MusicalPosition(
                        RationalTime::new(k, 12).unwrap(),
                    )),
                    duration: EventDuration::Musical(MusicalDuration(
                        RationalTime::new(1, 12).unwrap(),
                    )),
                    pitches: vec![IdentifiedPitch {
                        id: pid,
                        pitch: cmn_pitch(CmnNominal::G, 4),
                    }],
                    articulations: vec![],
                    dynamic: None,
                    ornaments: vec![],
                    stem: StemConfiguration,
                    grace: None,
                }))
                .expect("fresh event id");
            voice1.events.push(eid);
            triplet_members.push(eid);
        }

        let mut instance = StaffInstance::new(instance_id, staff_id);
        instance.voices.push(voice0);
        instance.voices.push(voice1);

        let region = Region {
            id: region_id,
            time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
            content: RegionContent::StaffBased(StaffBasedContent {
                staff_instances: vec![instance],
                ..Default::default()
            }),
            time_extent: TimeExtent {
                start: TimeAnchor::WallClock {
                    time: WallClockTime(0),
                },
                end: TimeAnchor::WallClock {
                    time: WallClockTime(1_000_000),
                },
            },
            staff_extent: StaffExtent {
                staves: vec![staff_id],
            },
            local_tempo_map: None,
            permits_spanning_slurs: false,
        };

        let mut score = Score::empty(idc.clone());
        score.identity = idc;
        score.staves = vec![Staff {
            id: staff_id,
            name: String::from("staff"),
            abbreviation: None,
            instrument,
            default_staff_lines: StaffLineConfiguration::default(),
            group: None,
            default_clef: Clef::treble(),
        }];
        score.instruments = vec![Instrument::new(instrument, String::from("instrument"))];
        score.events = arena;
        score.canvas = Canvas {
            regions: vec![region],
            ..Default::default()
        };

        let triplet_id: TupletId = score.identity.mint();
        score.cross_cutting.tuplets.push(Tuplet {
            id: triplet_id,
            ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"),
            members: triplet_members,
            parent: None,
            required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()),
        });
        // A nested child: the flat cascade cannot safely restate its ratio.
        score.cross_cutting.tuplets.push(Tuplet {
            id: TupletId::new(replica, 9_000_002),
            ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"),
            members: vec![],
            parent: Some(triplet_id),
            required_total: MusicalDuration(RationalTime::new(1, 8).unwrap()),
        });
        score
    }

    /// The mutation f3 test proper: a two-lane fragment (one note per voice)
    /// pastes onto a destination whose lane 0 (voice 0) is clear — its
    /// make-room and insert would succeed in complete isolation — and whose
    /// lane 1 (voice 1) overlaps a nested tuplet, refusing. `paste_document`
    /// builds *every* lane's ops before submitting *any* of them, so the
    /// whole paste errs and the destination is untouched.
    ///
    /// Mutation f3 (commit each lane's ops immediately after building them —
    /// e.g. via its own `self.apply_transaction(...)` call inside the lane
    /// loop — instead of accumulating every lane into one list and
    /// submitting it as a single transaction at the end): lane 0 (processed
    /// first, since `voice_ids`/`document.voices` iterate in lane order) has
    /// already committed its insert by the time lane 1's make-room refuses,
    /// so `canonical_bytes` changes even though the overall call still
    /// returns `Err` — this test dies on the `canonical_bytes` equality.
    #[test]
    fn paste_atomicity_rolls_back_mid_transaction_on_a_second_lanes_refusal() {
        let mut source = EditorSession::open(two_voice_one_note_each_score(), Box::new(StubSolver))
            .expect("renders");
        let events: Vec<EventId> = source.score().events.iter().map(|e| e.id()).collect();
        assert_eq!(events.len(), 2, "one note per voice");
        let pitches: Vec<PitchId> = events
            .iter()
            .map(|&eid| first_pitch_of(&source, eid))
            .collect();
        let rect = rect_covering_pitches(&source, &pitches);
        source.select_within(rect);
        let copy = source.copy_selection().expect("copies");
        let decoded = fragment::decode(&copy.fragment).expect("well-formed");
        assert_eq!(decoded.voices.len(), 2, "two lanes, one event each");

        let mut dest = EditorSession::open(
            two_voice_second_voice_has_a_nested_tuplet(),
            Box::new(StubSolver),
        )
        .expect("renders");
        let region = a_region_with(&dest, true);
        // Click voice 1's first triplet member's onset — `paste_at` resolves
        // the *staff instance* under the click (both voices share one), and
        // `paste_document` maps lane 0 -> the instance's voice 0, lane 1 ->
        // voice 1, positionally, regardless of which voice was clicked.
        let voice1 = dest
            .score()
            .staff_instances()
            .find(|(r, _)| *r == region)
            .expect("a staff instance")
            .1
            .voices[1]
            .id;
        let (_, pos, dur) = voice_events(&dest, voice1).into_iter().next().unwrap();
        let (_, _, origin_y) = region_staff_line(&dest, region);
        let at = click_for_position(&dest, region, &pos, origin_y + 1.0);
        let before = dest.score().canonical_bytes();

        let err = dest
            .paste_at(at, &GridResolution { step: dur }, &copy.fragment)
            .expect_err("lane 1 overlaps the nested tuplet");
        assert_eq!(err, EditorError::OverlapsTuplet);
        assert_eq!(
            dest.score().canonical_bytes(),
            before,
            "atomic: lane 0's insert must not land just because lane 1 refused"
        );
    }

    /// r3 (T2 W4b): the atomicity guarantee still holds with a cross-cutting
    /// structure aboard the paste. Reuses the two-lane refusal scenario
    /// above (`two_voice_second_voice_has_a_nested_tuplet` as the
    /// destination), now with a slur spanning lane 0's two source events —
    /// the whole paste, the slur's `CreateCrossCutting` included, still
    /// rolls back byte-identically when lane 1's make-room refuses.
    ///
    /// Mutation r3 (the same "commit per lane inside the loop" mutation as
    /// f3 above, reapplied now that lane 0 also carries a slur): lane 0's
    /// two inserts land before lane 1's refusal is discovered, so
    /// `canonical_bytes` changes even though the call still returns `Err` —
    /// this test dies on the `canonical_bytes` equality. Confirmed live,
    /// then reverted (see the packet report).
    #[test]
    fn paste_atomicity_rolls_back_mid_transaction_with_a_slur_aboard() {
        let mut source = EditorSession::open(
            two_voice_first_voice_has_two_notes_with_a_slur_second_voice_one_note_score(),
            Box::new(StubSolver),
        )
        .expect("renders");
        let events: Vec<EventId> = source.score().events.iter().map(|e| e.id()).collect();
        assert_eq!(events.len(), 3, "two notes in voice 0, one in voice 1");
        let pitches: Vec<PitchId> = events
            .iter()
            .map(|&eid| first_pitch_of(&source, eid))
            .collect();
        let rect = rect_covering_pitches(&source, &pitches);
        source.select_within(rect);
        let copy = source.copy_selection().expect("copies");
        assert!(copy.dropped.is_empty(), "the slur is fully inside the copy");
        let decoded = fragment::decode(&copy.fragment).expect("well-formed");
        assert_eq!(decoded.voices.len(), 2, "two lanes");
        assert_eq!(
            decoded.voices[0].events.len(),
            2,
            "lane 0 carries both slurred notes"
        );
        assert_eq!(decoded.slurs.len(), 1, "the slur made it into the fragment");

        let mut dest = EditorSession::open(
            two_voice_second_voice_has_a_nested_tuplet(),
            Box::new(StubSolver),
        )
        .expect("renders");
        let region = a_region_with(&dest, true);
        // Same click strategy as the test above: voice 1's first triplet
        // member's onset — `paste_document` maps lane 0 -> the instance's
        // voice 0, lane 1 -> voice 1, positionally.
        let voice1 = dest
            .score()
            .staff_instances()
            .find(|(r, _)| *r == region)
            .expect("a staff instance")
            .1
            .voices[1]
            .id;
        let (_, pos, dur) = voice_events(&dest, voice1).into_iter().next().unwrap();
        let (_, _, origin_y) = region_staff_line(&dest, region);
        let at = click_for_position(&dest, region, &pos, origin_y + 1.0);
        let before = dest.score().canonical_bytes();

        let err = dest
            .paste_at(at, &GridResolution { step: dur }, &copy.fragment)
            .expect_err("lane 1 overlaps the nested tuplet");
        assert_eq!(err, EditorError::OverlapsTuplet);
        assert_eq!(
            dest.score().canonical_bytes(),
            before,
            "atomic: the slur-bearing lane 0 must not land just because lane 1 refused"
        );
    }

    /// Coordinator-added (T2 W4a review); extended T2 W4b (2026-07-24) to pin
    /// a slur aboard the paste too. The commit's transactional **shape** is
    /// itself canonical surface. The atomicity test above pins
    /// build-everything-before-committing-anything (its refusal fires at
    /// make-room time, inside `paste_document`'s build), but nothing there
    /// reaches the commit itself — a mutation that keeps the build intact and
    /// commits the finished op list **per op** survives it, because make-room
    /// and the reducer agree by design and no mid-commit rejection is
    /// reachable on that input. The difference that IS observable: a
    /// transaction's `DeclareTransaction` descriptor grants its members
    /// descriptor-precedence in concurrent reduction, so a paste emitting bare
    /// ops would merge differently at a peer even though it applies
    /// identically here. This pins the emitted stream: one descriptor plus
    /// **exactly** every member — two inserts, the carried respell, and (W4b)
    /// the re-minted slur's `CreateCrossCutting` — appended by a single
    /// multi-op paste.
    ///
    /// Re-proven kill (W4b, per the packet's own instruction): temporarily
    /// replacing the `if ops.len() == 1 { self.apply(...) } else {
    /// self.apply_transaction(...) }` dispatch in `paste_document` with a
    /// per-op `self.apply` loop still kills this test — `descriptors` drops
    /// to 0 (a per-op loop never mints a `DeclareTransaction` at all), tripping
    /// the `descriptors == 1` assertion. Confirmed live, then reverted (not
    /// left in the tree — see the packet report).
    #[test]
    fn paste_emits_one_transaction_descriptor_plus_members() {
        use epiphany_core::{RegionContent, Slur, SlurId, SlurKind, SpanStyle};

        let mut score = small_metric_score(4);
        let events: Vec<EventId> = {
            let RegionContent::StaffBased(content) = &score.canvas.regions[0].content else {
                panic!("region 0 is staff-based");
            };
            content.staff_instances[0].voices[0].events.clone()
        };
        // A slur fully inside the copied/pasted range (events 0 and 1) — it
        // must ride along as one more transaction member.
        let slur_id: SlurId = score.identity.mint();
        score.cross_cutting.slurs.push(Slur {
            id: slur_id,
            start_event: events[0],
            end_event: events[1],
            kind: SlurKind::Legato,
            curvature_override: None,
            style: SpanStyle::default(),
        });

        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
        let region = a_clean_metric_region(&session);
        let voice = primary_voice(&session, region);
        let before = voice_events(&session, voice);
        let source_pitch = first_pitch_of(&session, before[0].0);
        session
            .apply(OperationKind::RespellPitch(RespellPitchOp {
                pitch: source_pitch,
                spelling: PitchSpelling::cmn(CmnNominal::D, 4),
            }))
            .expect("the respell applies");
        let pitch0 = first_pitch_of(&session, before[0].0);
        let pitch1 = first_pitch_of(&session, before[1].0);
        let rect = rect_covering_pitches(&session, &[pitch0, pitch1]);
        session.select_within(rect);
        let copy = session.copy_selection().expect("the selection copies");
        assert!(copy.dropped.is_empty(), "the slur is fully inside the copy");
        let decoded = fragment::decode(&copy.fragment).expect("well-formed");
        assert_eq!(decoded.slurs.len(), 1, "the slur made it into the fragment");

        let far = MusicalPosition(RationalTime::new(50, 4).unwrap());
        let (_, _, origin_y) = region_staff_line(&session, region);
        let at = click_for_position(&session, region, &far, origin_y + 1.0);

        let applied_before = session.applied_operations().len();
        let outcome = session
            .paste_at(at, &grid(1, 4), &copy.fragment)
            .expect("the paste applies");
        assert_eq!(outcome.events_inserted, 2);
        assert_eq!(outcome.slurs_inserted, 1);
        assert_eq!(outcome.ties_inserted, 0);

        let new = &session.applied_operations()[applied_before..];
        let descriptors = new
            .iter()
            .filter(|e| {
                matches!(
                    &e.payload,
                    OperationPayload::Primitive(OperationKind::DeclareTransaction(_))
                )
            })
            .count();
        assert_eq!(
            descriptors, 1,
            "a multi-op paste emits exactly one transaction descriptor"
        );
        // Exactly: descriptor + two inserts + the carried respell + the
        // re-minted slur's CreateCrossCutting.
        assert_eq!(
            new.len(),
            5,
            "descriptor + two inserts + the carried respell + one CreateCrossCutting, got {}",
            new.len()
        );
    }

    /// Boundary slur: a slur spans event 0..event 2 of a four-note voice;
    /// selecting only events 0 and 1 cuts it at the selection boundary. The
    /// resulting fragment omits the slur, and `copy_selection`'s outcome
    /// names it in `dropped`.
    ///
    /// Mutation f6 (suppress the dropped-report — e.g. drop the `dropped.push`
    /// arm and just fall through to `(None, None) => {}`): this test dies.
    #[test]
    fn copy_selection_drops_a_boundary_cut_slur_and_reports_it() {
        use epiphany_core::{RegionContent, Slur, SlurId, SlurKind, SpanStyle};

        let mut score = small_metric_score(4);
        let events: Vec<EventId> = {
            let RegionContent::StaffBased(content) = &score.canvas.regions[0].content else {
                panic!("region 0 is staff-based");
            };
            content.staff_instances[0].voices[0].events.clone()
        };
        let slur_id: SlurId = score.identity.mint();
        score.cross_cutting.slurs.push(Slur {
            id: slur_id,
            start_event: events[0],
            end_event: events[2],
            kind: SlurKind::Legato,
            curvature_override: None,
            style: SpanStyle::default(),
        });
        let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");

        let pitch0 = first_pitch_of(&session, events[0]);
        let pitch1 = first_pitch_of(&session, events[1]);
        let rect = rect_covering_pitches(&session, &[pitch0, pitch1]);
        session.select_within(rect);

        let copy = session
            .copy_selection()
            .expect("the notes themselves still copy fine");
        assert_eq!(
            copy.dropped,
            vec![DroppedItem::Slur(slur_id)],
            "the boundary-cut slur is reported dropped"
        );

        let decoded = fragment::decode(&copy.fragment).expect("a well-formed fragment");
        assert!(
            decoded.slurs.is_empty(),
            "the cut slur must not appear in the fragment text itself"
        );
        let fragment_event_count: usize = decoded.voices.iter().map(|v| v.events.len()).sum();
        assert_eq!(
            fragment_event_count, 2,
            "exactly the two targeted (non-slur-endpoint-3) notes made it into the fragment"
        );
    }
}
