epiphany/crates/epiphany-editor-core/DECISIONS.md

37 KiB

epiphany-editor-core — Decisions

Decision log for the headless editing seam (EditorSession). Older decisions for this crate were recorded in session notes and the Pass-12 batch rows it filed (P12-E4, P12-E5); this file starts with their ratification.

Pass 12 G-pass (2026-07-07): E4/E5 are ratified

Dispositions in spec/PASS12_RATIFICATION_LOG.md ("G-pass tranche"). E4 adopt-as-implemented (req:format:barrier-matching): target-free operations (SetMetadata, DeclareTransaction) are matched by score-wide barriers only; opaque Registered operations match fully conservatively. E5 semantics ratified (req:format:unsafe-tombstone): crossing a barrier immediately deactivates the extension's remaining barriers; the crossing MUST be durably recorded at the next commit; a tombstoned required = true extension leaves the bundle read-only for dependents. The manifest-side byte encoding is deferred to the Binary Format companion (new open question there: the manifest is frozen at major 0, so the record rides the blob layer or a new chunk kind — next bundle-format tranche); extensions_requiring_tombstone() remains the producer awaiting that consumer.

Selection v2 — the selection set with an anchor (2026-07-23, T2-W1)

Dispatched under spec/CONTRACT_EDITOR_T2_SELECTION.md §W1. Replaces the session's single Option<Selection> (EditorSession::selection, formerly lib.rs:304) with a private SelectionSet { members: Vec<Selection>, anchor: Option<LayoutObjectId> }. Public surface: selections() -> &[Selection], anchor() -> Option<&Selection>, click/select (replace-with-single, unchanged), toggle_at(point) (add/remove one member), select_within(rect: BoundingBox) (paint-ordered rubber-band set), clear_selection(); selection() is kept as the anchor, copied — it fell out of anchor() directly, so it was kept rather than removed (the GUI crate's call sites are out of this packet's blast radius per the contract, and the compatibility read costs nothing).

The set model. Anchor identity is tracked by LayoutObjectId, not duplicated as a Selection copy, so anchor()'s lookup in members can never desync from a member's relayout-refreshed source. Two constructors populate members: replace_single (click/select) and the paint-ordered path built by select_within itself (see below) via SelectionSet::replace_members, which trusts an already-ordered, already-deduped Vec<Selection>. toggle_at keeps members in add order (not re-sorted into paint order) — the contract's "a selection set in paint order" is select_within's contract specifically; nothing in the spec requires toggling to re-derive true paint order for an arbitrary mixed-source set, and doing so would need a public paint-order query HitTestMap does not expose (its paint_order() ranking is private, by design — only hit/within are public).

Anchor-fallback rule (one rule, two call sites). Both toggle-removing-the- anchor and SelectionSet::reresolve (relayout) apply the same rule: the anchor falls to the member now occupying its old position (i.e. the member that was right after it), wrapping to the first member if it was last (or only), or to None if the set emptied. reresolve computes this over the surviving members' original positions, so a member that also dropped is skipped rather than treated as "the next one". Tested directly (m3): dropping the anchor's own member via a plain single delete (bypassing delete_selection, to isolate the mechanism from the batch path) leaves the other selected member as both the sole survivor and the new anchor.

select_within and the ledger-line finding. A hit-test region's source (the score object) and layout_object (its layout-id anchor) are not the same identity when the object manifests as more than one primitive: a note needing ledger lines gets the ledger strokes synthesized from its Pitch source (Provenance::synthesized, distinct stable_id per synthesis kind/key) and its own notehead glyph (Provenance::manifested/projected, a different stable_id) — same source, different layout_objects. Deduping a within(rect) result by layout_object (the first design here) does not merge them into one selected object; it must dedupe by source, and the group's representative layout_object must prefer the non-synthesized occurrence when one exists — tracking a ledger stroke's id would make the member wrongly drop on a relayout that keeps the note but moves it back on-staff (no ledger needed), even though the note itself is still live. This grouping needs HitRegion::synthesis, so it lives in EditorSession::select_within (which sees full HitRegions), not inside SelectionSet (which only ever sees bare Selection values). Verified with a dedicated fixture (a note pushed to octave 8, forcing real ledger-line strokes): one member, its layout_object equal to the notehead's own region, not a ledger's.

Single-target intents retarget to the anchor (transpose_selection, alter_selection on a one-member set, move_selection_staff_step, add_note_to_selection, insert_note_after_selection, set_selection_duration): each now reads self.selection.anchor().copied() where it used to read the bare field. Behavior with one member is unchanged — every pre-existing test in this file passed without modification once the five call sites were mechanically updated (no test needed a behavioral change, only the internal accessor changed).

Batch delete_selection: apply_transaction, N=1 unwrapped. Every member maps to its own delete op (matching today's single-object mapping exactly: PitchDeleteIdentifiedPitch, EventDeleteEvent with the plain NotInTuplet compensation — so a tuplet member is a genuine refusal-worthy target, not a construction error). One member: self.apply(kind), exactly today's op stream. More than one: self.apply_transaction(...), whose reducer-level all-or-nothing rollback (reduce_transaction_block, is_member_failure) is what atomicity actually rests on — verified (m2) that without the transaction wrapper, a refused tuplet-member delete is merely a silent, accepted no-op (not a rejection: graph_delete_precondition's TupletCompensationInvalid returns a clean OperationEffect::NoOp, which registers as a conflict only inside reduce_transaction_block, never for a standalone apply), so the other selected member's delete would have gone through — the exact partial-batch bug the transaction wrapper exists to prevent. This mirrors the crate's pre-existing "no transaction for one op" idiom (insert_note_at, set_selection_duration: if ops.len() == 1 { apply } else { apply_transaction }), now applied to selection-set size.

Batch alter_selection: not wrapped in apply_transaction, by design — the N=1 finding generalized. TransposeIntervalOp.targets is already a CanonicalSet<PitchId> (a target set, not a scalar target), and the reducer is already atomic over it end to end (req:opcat:transpose-interval-atomic — every target's new value and spelling is resolved before any of them is written, and the whole operation refuses if any target cannot transpose). So a selection's pitches ride one primitive TransposeInterval naming every one of them, applied via plain self.apply, at every set size — never apply_transaction. This is not a smaller version of the same "avoid a gratuitous wrapper at N=1" reasoning delete_selection needed; it is the discovery the contract's N=1 clause was fishing for, taken to its conclusion: wrapping in apply_transaction would (a) add a DeclareTransaction envelope this method has never emitted, breaking N=1 op-stream compatibility outright, and (b) at N>1, replicate — forever, in the canonical log — a strictly more verbose encoding (one descriptor + N single-target ops) than the wire format already provides for exactly this case. Non-pitch members are silently ignored (not refused): "alter" has nothing to say about a selected slur or rest, unlike delete. Verified: a single-member set mints the identical op (applied_operations().len() == 1, same targets/interval) as before this packet; a multi-pitch batch mints exactly one TransposeInterval naming every selected pitch; a batch containing one untransposable pitch (AcousticRealization::AbsoluteHz, pinned) leaves every selected pitch — including the otherwise-transposable one — unmoved (graph_changed == false), proving the reducer's own atomicity, not this method's plumbing, is what's carrying the guarantee.

The clipboard fragment projection (2026-07-23, T2-W4a)

Dispatched under spec/PLAN_EDITOR_APP.md §Ruling E (granted 2026-07-23) and spec/CONTRACT_EDITOR_T2_SELECTION.md §W4. New module src/fragment.rs (the versioned s-expression format, private/pub(crate) types) plus three new EditorSession intents in lib.rs: copy_selection, paste_at, paste_over_selection. Blast radius: epiphany-editor-core only — the GUI clipboard wiring is packet W4b.

Grammar: reuse the Text Projection's leaf productions directly, not a re-derived lookalike. Pitch, PitchSpelling, MusicalDuration, SlurKind, TieClass, SpanStyle, CurvatureOverride, ArticulationMark/DynamicMark/OrnamentMark/StemConfiguration/GraceKind/StaffPosition already implement epiphany_core::textvalue::TextValue — the same Sexp/TextValue/read_sexp machinery epiphany-textproj/epiphany-ops's textproj_leaf build on, generated by epiphany-core's struct_codec! / cstyle_enum_codec! macros alongside their binary codec. The fragment format calls .project()/::parse() on these directly rather than reinventing their textual shape, and needs no new dependency on epiphany-textproj: epiphany_core::textvalue is already public, and epiphany-editor-core already depends on epiphany-core. What is bespoke is only the document-shaped container Ruling E says the fragment must NOT borrow from TP's document grammar: (epiphany-fragment (0 1 0) VOICES SLURS TIES), with EventRef { voice: u32, event: u32 } (a position in the fragment's own per-voice lanes) as the sole way anything inside a fragment refers to anything else — never a source EventId/PitchId/SlurId/TieId. Worked example, captured live from copy_paste_round_trip_preserves_values_with_fresh_ids (a two-note voice; the first note carries an authored spelling override, the second does not — no cross-cutting structures span this range):

(epiphany-fragment (0 1 0) ((voice ((event (ratio 0 1) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) (some (pitch-spelling (cmn d) () 4 (spelling-render-hints false false false false))))) () () () stem-configuration ())) (event (ratio 1 4) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) ())) () () () stem-configuration ()))))) () ())

API, as landed (diverges from the packet's indicative shape in two places, both flagged there and re-flagged here):

  • copy_selection(&self) -> Result<CopyOutcome, EditorError>&self, not &mut self (it never mutates); CopyOutcome { fragment: String, dropped: Vec<DroppedItem> }. The indicative shape had copy_selection() -> Result<String, EditorError> with dropped living on PasteOutcome; the required boundary-slur/tie test needs the dropped report at the point the closure decision is made, which is copy time, against the source selection — a fragment carries no memory of what it isn't, so paste could never reconstruct this list. PasteOutcome { outcome: EditOutcome, events_inserted: usize } therefore carries no dropped.
  • paste_at(&mut self, point: Point, grid: &GridResolution, fragment: &str) -> Result<PasteOutcome, EditorError>, paste_over_selection(&mut self, fragment: &str) -> Result<PasteOutcome, EditorError> — match the indicative shape.
  • fragment::{FragmentError, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_EVENTS, MAX_FRAGMENT_NESTING_DEPTH} are re-exported at the crate root; EditorError::InvalidFragment(fragment::FragmentError) folds decode failures in via From. New EditorError variants: PartialTupletSelection { tuplet } (copy-side closure refusal), InsufficientVoicesForFragment { needed, available } (paste-side lane policy, below), EmptyFragment (a decoded fragment naming no events).

Closure v1, as implemented. Tuplet refusal and the slur/tie copy-or-drop-and-report decision are both evaluated in copy_selection against the live score (fragment.rs never sees a tuplet, a Score, or a selection — it only knows its own already-built FragmentDocument). A slur whose start_event/end_event both resolve to fragment-local EventRefs is carried into the fragment (kind/curvature_override/style copied verbatim, since none are identities); exactly one resolving is a boundary cut, reported via DroppedItem::Slur/Tie; neither resolving means the slur/tie has nothing to do with this copy and is silently absent (not a "drop" — it was never in scope). A partially-covered tuplet's members short-circuit the whole copy before any fragment content is built.

Points Ruling E left underdetermined, decided here (flagged per the brief):

  1. Event-kind scope. Ruling E names no event-kind boundary for "copies". copy_selection scopes to what make_room already treats as copyable — a live, metric Pitched/Rest event — mirroring the crate's one other place this exact line gets drawn, rather than inventing a second boundary. Non-metric events and every other Event variant (Unpitched/Indeterminate/Trajectory/Graphic/Cue) are out of v1.
  2. A selection member that is not a copyable pitch/event is skipped, not refused — a deliberate departure from delete_selection's hard-refusal precedent, discovered while writing the required select_within-driven tests. A rubber-band select_within over a real staff routinely also selects incidental geometry it visually crosses — staff lines, a note's own stem — verified directly (a tight rect around two on-staff notes still selected the staff line their noteheads sit on, plus both notes' stems, five members total for two intended notes). A hard refusal (delete_selection's own rule: anything that is not a pitch/event errors the whole call) would make geometric copy nearly unusable in practice. copy_selection instead mirrors alter_selection's established "silently ignored" precedent (this file, above): a member that is not a pitch/event, or a pitch/event that does not resolve to a live copyable note/rest, is skipped; the whole copy still refuses (WrongSelection) only if nothing usable remains. A note's own stem (an Event-sourced region distinct from its Pitch-sourced notehead) being incidentally selected alongside its notehead is harmless either way — both resolve to the same EventId and collapse in the BTreeSet.
  3. Multi-lane paste placement is resolved positionally, refusing rather than guessing when short. Ruling E specifies the destination staff/voice for both placement forms but not a policy for a fragment naming more than one voice lane. This session maps fragment lane i onto the destination staff instance's i-th voice (StaffInstance::voices[i]) for both paste_at (rooted at the clicked staff instance) and paste_over_selection (rooted at the anchor's own staff instance); InsufficientVoicesForFragment refuses cleanly when the destination has fewer voices than the fragment has lanes, rather than collapsing lanes onto one voice or dropping the extras silently.
  4. Slur/tie closure captures into the fragment format fully; paste does not yet replay a captured one. A fully-contained slur/tie is carried in the fragment text (satisfying "copies" as a wire-format guarantee — the data is not lost, and a future paste enhancement needs no format bump to consume it), but Ruling E's own placement bullet names exactly InsertEvent + RespellPitch as what paste mints, and no required test exercises re-minting a pasted slur/tie (CreateCrossCutting, which would need its own SlurId/TieId high-water-mark minter alongside mint_event_id/mint_pitch_id). Filed here as a named follow-up, not a silent gap: a fragment carrying a captured slur/tie today decodes and pastes its notes correctly; the slur/tie itself is inert cargo until a later packet wires the mint.
  5. A tie's explicit pitch_pairing is not carried; captured ties fall back to None (default enharmonic pairing on any future replay). pitch_pairing: Option<Vec<(PitchId, PitchId)>> keys on source PitchIds, which the fragment deliberately never carries (Ruling E: no object ids); remapping specific pairings through per-pitch ordinals was judged not worth the added grammar for a rare feature with no test coverage requirement. TieClass/SpanStyle are still carried in full.

Untrusted-input caps, each a named public constant with its own value-asserting rejection test in fragment.rs: MAX_FRAGMENT_BYTES = 1 << 20 (byte length, checked first — cheapest rejection); MAX_FRAGMENT_EVENTS = 4096 (checked after structural parse, since counting needs typed voices); MAX_FRAGMENT_NESTING_DEPTH = 64 ((-nesting, checked by one linear, non-recursive scan over the raw text before read_sexp's recursive-descent reader ever sees adversarial input — the scan is string-literal-aware, so a legitimate catalog-id string's own characters can never spuriously trip it). An unrecognized major is rejected immediately after the version triple is read and before Vec<FragmentVoice>::parse or its siblings ever attempt to interpret the body — "never partially parsed" verified directly (a major-1 fragment whose body would not even parse under today's grammar still reports UnsupportedVersion, not a parse error, proving the gate runs first).

Paste atomicity. paste_document builds the entire op list — every lane's make-room clears plus every fragment event's InsertEvent (+ RespellPitch for a carried spelling override, insert-before-its-own-respell, the same discipline make_room_ops's split-tail loop already uses) — before calling apply/apply_transaction even once; a refusal discovered while building it (make_room overlapping a nested tuplet) therefore can never leave a partial mutation, regardless of how the final application is shaped. Because of that, the packet's own suggested atomicity scenario ("make-room hits a refusal") cannot distinguish a transaction-committing implementation from one that applies ops individually — both return Err before anything would be applied either way (verified by construction, not asserted away: paste_refuses_cleanly_on_a_nested_tuplet_and_changes_nothing keeps that scenario as a real, useful test of a different property). The mutation that does separate the two designs needs a refusal that a second lane's make-room hits after a first lane's ops have already been built — paste_atomicity_rolls_back_mid_transaction_on_a_second_lanes_refusal pastes a two-lane fragment onto a destination whose voice 0 is clear and voice 1 carries a nested tuplet; mutating the commit to run per-lane inside the loop (instead of once, over every lane's accumulated ops) lands voice 0's insert before voice 1's refusal is discovered, changing canonical_bytes even though the call still returns Err — killed, confirmed live.

Slur/tie replay on paste (2026-07-24, T2-W4b) — closing the W4a follow-up

Dispatched as the W4b packet's Half 1, ratified by the user 2026-07-23. W4a (above, point 4) captured a fully-contained slur/tie into the fragment format but never re-minted it on paste, filed as a named follow-up rather than papered over. This closes it: paste_document now re-mints every fragment slur/tie, once both endpoints resolve to freshly-minted destination events, as a fresh CreateCrossCutting op — inside the same single atomic paste transaction as the inserts/respells (Ruling E), never a second unit.

Id mapping. A fragment's EventRef names an event by its fragment-local position, never a source id (Ruling E: no object ids) — so paste needs its own map from that position to the freshly minted destination EventId. Implemented as event_map: Vec<Vec<EventId>> (event_map[voice_ordinal][event_ordinal]), built alongside the existing per-voice insert loop and pushed once per lane (an empty Vec for an empty/skipped lane) so indices stay aligned with the fragment's own voice ordinals — a plain array index, not a fallible lookup, because fragment::decode already rejected any dangling EventRef before this method ever sees the document (the module's own documented invariant: "an EventRef decode validated resolves — nothing downstream needs to re-check"). A BTreeMap<fragment::EventRef, EventId> was the first design considered; EventRef derives no Ord, and adding one purely for a paste-local, non-format concern was judged not worth extending a purpose-built, already-reviewed type's derive set. Fresh SlurId/TieId values come from two new session minters (mint_slur_id/mint_tie_id, mirroring mint_event_id/mint_pitch_id's three-source high-water-mark discipline: base, current score, and this session's authored history) plus matching Minter::slur/Minter::tie fields, so several cross-cutting mints within one paste never collide and a since-deleted or since-undone slur/tie's id is never reused. Unlike a pitch, a deleted cross-cutting structure has no separate score-level tombstone list to chain (DeleteCrossCuttinggraph_delete_cross_cutting removes it from score.cross_cutting.{slurs,ties} outright, reduce.rs:3539), so the authored source is what actually carries a deleted/undone slur/tie's id forward — the same reasoning mint_pitch_id's doc already gives for pitches, just with no base/score tombstone list to add.

Ordering: inserts before cross-cuttings, same transaction. The reducer's own create_cross_cutting precondition (reduce.rs:3249) requires every anchor event already Live in its per-envelope object registry — checked against reduction's own evolving state as the transaction's envelopes apply in order, not against self.score (which does not yet contain the paste at advisory-check time). paste_document therefore builds every lane's InsertEvents first, then every CreateCrossCutting after, in one ops list — the existing apply/apply_transaction dispatch (N=1 unwrapped, else one DeclareTransaction + members) needed no change at all; a freshly-minted event and the slur/tie naming it simply ride the same transaction as any other multi-op paste. The pre-mint advisory gate (epiphany_ops::validate::advisory_violations) is unaffected: its CreateCrossCutting(Slur) check resolves both endpoints' regions against self.score (the pre-transaction state), so a freshly-minted event resolves to None on both sides and the boundary check passes vacuously — documented as intentional in that module ("conservative for the rare member that only violates against another member's intermediate effect").

Tie pairing: None, and this is NOT a structural block. A replayed tie always carries pitch_pairing: None — the fragment format never carries the source's explicit pairing at all (W4a's FragmentTie has no such field; Ruling E: no object ids, and pairing keys on source PitchIds). The packet's brief asked to fail closed (skip tie replay, report it) if the model requires a pairing the fragment cannot express. Investigated and ruled out: Tie::pitch_pairing: Option<...> with None means "pair all pitches by enharmonic matching in ascending PitchId order" — a fully representable, spec-legitimate value (Chapter 5 §"Ties"; epiphany_core::invariants::check_tie_pairing's None arm implements exactly this rule as a checkable invariant, not a requirement the reducer enforces at mint time). create_cross_cutting's only preconditions are (a) the structure id is not already live/tombstoned and (b) its anchor events are live — nothing inspects pitch_pairing at all (materialize_graph_cross_cutting pushes the Tie value verbatim). The pairing-consistency invariant check lives in epiphany_core::invariants, a graph-wide checker this session's apply/apply_transaction never runs (the crate's own tests call epiphany_core::check_invariants explicitly, separately, when they want it — lib.rs's commit does not). So: full tie replay is implemented, unconditionally, with pitch_pairing: None; no dropped-style outcome field was needed because there is no fail-closed case to report on this path. PasteOutcome gained slurs_inserted / ties_inserted counts instead (both always equal to the fragment's own slurs.len()/ties.len() given a well-formed paste — a straightforward "how many landed" report, not a fallibility channel).

CopyOutcome gained events_copied: usize too (same packet, needed by W4b's Half 2 GUI status line: "N event(s) copied"). The fragment grammar/decoder (fragment.rs) are crate-private by design (Ruling E's format is application-internal, not a public surface), so a caller outside this crate has no way to learn how many events a copy actually captured without this field — copy_selection now sums document.voices[*].events.len() once, right before encoding, and returns it alongside the fragment text.

Tests. paste_replays_a_contained_slur_as_a_fresh_cross_cutting_structure (r1) and paste_replays_a_contained_tie_with_the_default_pairing (r2) each build a source fixture with the cross-cutting structure captured directly on the raw Score (mirroring copy_selection_drops_a_boundary_cut_slur_and_reports_it's technique), copy, paste far away in the same session, and assert the destination carries a second, fresh-id structure spanning exactly the two pasted events — r2's source tie carries an explicit pitch_pairing on purpose, so the assertion that the replayed tie's pairing is None proves the drop is real, not merely untested. paste_atomicity_rolls_back_mid_transaction_with_a_slur_aboard (r3) reuses the existing two-lane refusal fixture shape (two_voice_second_voice_has_a_nested_tuplet as the destination) with a new source fixture whose lane 0 carries two notes and a slur; the whole paste — cross-cutting op included — still rolls back byte-identically when lane 1's make-room refuses. paste_emits_one_transaction_descriptor_plus_members (the coordinator-added transaction-shape test) now pins the exact member count with a slur aboard (descriptor + 2 inserts + 1 respell + 1 CreateCrossCutting = 5), re-proven live against the same "per-op loop instead of the transaction dispatch" mutation the test's own doc comment already described for the pre-slur version — the mutation still kills it (0 descriptors instead of 1), confirmed and reverted.

The note-entry caret (2026-07-24, T3-W1)

Dispatched under spec/CONTRACT_EDITOR_T3_CARET.md §W1. Adds EditorSession's caret: Option<Caret> (Caret { voice, position, entry_duration }, all pub fields) plus caret(), clear_caret(), set_caret_at(point), set_entry_duration(duration), advance(), retreat(), enter_nominal(nominal), enter_pitch(pitch), enter_rest(), the pure midi_note_to_pitch(u8) -> Pitch, and x_at_position(region, within, position). Session-local, never in the op log; undo does not move it. EditorError::NoCaret is a new variant (see below).

NoCaret, not an overload of NoSelection. The contract asked to check whether NoSelection's shape generalizes. It doesn't cleanly: a caret and a selection are independent session-local cursors (one can be set while the other is empty, e.g. mid-caret-entry with nothing selected), and NoSelection's doc ("An intent needed a selection but none is set") would read as a lie for a caret-only intent. NoCaret is its own variant, same shape, one line in Display.

One insertion core, reusing the pencil's machinery verbatim. enter_nominal/enter_pitch/enter_rest all funnel to a private enter_at_caret(pitch: Option<Pitch>): builds [start, start+entry_duration) from the caret, calls the same make_room/make_room_ops/Minter the pencil (insert_note_at) uses, appends one InsertEvent, dispatches through the existing apply/apply_transaction N=1 idiom, then advances the caret by the entry duration only after the apply/transaction succeeds (the ? on the apply result runs before the advance, so a refused edit changes nothing including the caret). enter_rest passes pitch: None (an empty pitch list mints a Restnote_event's existing behavior, unchanged).

Caret re-resolution rides the same seam as selection re-resolution. reresolve_caret (clears the caret iff caret.voice is absent from self.score.voices()) is called from the same three sites reresolve_selection already is — commit, undo, redo — right after self.install(materialized). Unlike the selection set's survivor/fallback rule, a point cursor has no fallback: vanish just clears it.

The vanish test uses a real op, not a synthetic swap. The contract's own fallback ("if no current op can remove a voice, ... test the clear via ... a synthetic score swap") turned out not to be needed: epiphany-ops::DeleteVoiceOp exists (Chapter 6 §6.10, "tombstone an empty voice … precondition no-op if it still has live events") and cleanly deletes an empty voice. caret_clears_when_its_voice_is_deleted hand-adds a fresh empty Voice to a fixture's staff instance (score.identity.mint()

  • Voice::user(id), pushed via RegionContent::staff_based_mut()), points the caret at it directly (session.caret = Some(...), legal from the same- crate tests submodule), applies DeleteVoice, and asserts the caret is None afterward.

Octave inference: implemented, table-tested, and its tie-break is provably unreachable through the table. infer_octave(reference: Option<(CmnNominal, i8)>, nominal) -> i8 computes the nearest candidate octave in diatonic staff steps (diff = ref_index - nominal_val = 7q + r, 0 <= r < 7; down candidate at distance r, up candidate at distance 7 - r; nearer_is_down(r, 7 - r) picks). Finding, verified by direct computation, not assumed: because the candidate octaves for a fixed nominal are exactly 7 diatonic steps apart and 7 is odd, r and 7 - r can never be equal for an integer r (equal would need r = 3.5) — so no (reference, nominal) pair drawn from the seven CMN letters can ever produce a genuine tie. The contract's own suggested case, "ref G4 enter D" (a perfect fifth either direction), was flagged by the contract itself as needing verification ("verify this IS the equidistant case in staff steps and if not, construct the true equidistant pair") — checked directly: down is 3 staff steps (G4→F4→E4→D4), up is 4 (G4→A4→B4→C5→D5); not a tie in staff steps (it's only a tie in semitones — a P5 both ways — which is not the metric the contract pins: "nearest ... in diatonic staff steps"). No true equidistant letter pair exists to substitute for it. Consequence for testing: the downward tie-break (nearer_is_down, down_distance <= up_distance) is implemented as specified and documented, but octave_inference_table's four contract rows cannot kill a flipped comparison (verified directly — see the mutation report). nearer_is_down is therefore unit-tested on its own, directly, with a synthetic tied input (nearer_is_down(3, 3)) that no real call site can ever produce; this proves the written comparison is correct without pretending the table exercises it.

Reference-pitch policy, two silent decisions the contract left open, both in reference_pitch_before(voice, position): (1) chord reference — when the nearest preceding note is a chord, its first pitch (as authored) is the reference; the contract names no chord tie-break. (2) non-CMN reference — a preceding note whose pitch is not a Cmn scale position (a JI/serial score) is treated the same as "no reference" (falls back to octave 4), since there is no staff-step notion to measure from. Both are flagged here rather than silently baked in.

Naturals-only / sharp-spelled MIDI, as pinned. enter_nominal mints via cmn_pitch(nominal, octave) (alteration always 0 — accidentals are a follow-up transpose gesture, unchanged). midi_note_to_pitch is new and needed an alteration-bearing pitch constructor cmn_pitch doesn't have; rather than duplicate cmn_pitch's body, it now delegates to a new chromatic_pitch(nominal, alteration, octave), and midi_note_to_pitch is chromatic_pitch's only non-zero-alteration caller. The 12-entry table is the ordinary MIDI-to-SPN convention (60 = C4, 69 = A4, every black key sharp) with octave note/12 - 1 (integer division; correct for the full u8 range including the low notes below MIDI 12, where it goes negative into i8).

x_at_position's signature mirrors position_anchors, not position_at's point-based one. The contract left the shape open ("region-or-point-context"). position_at starts from a Point because a click is a point; the caret has no click, only a (region-bearing voice, position), and — under cast-off geometry — knowing which system a bare musical position falls on requires either a point to test against (containing_system) or scanning every system's anchor coverage, which is GUI-side work this packet doesn't own. So x_at_position(region: RegionId, within: Option<&Rect>, position: &MusicalPosition) -> Option<f32> takes exactly position_anchors's own two scoping parameters — within = None reads the whole region as one flat run (the stub solver, and every test here); a GUI drawing into one system supplies that system's box the same way position_at derives one via containing_system. The engine, forward_x, is the literal mirror of invert_x with the roles of x and musical time swapped (interpolate the bracketing segment; extrapolate the nearest end segment's slope outside the anchored span).

retreat's clamped subtraction. There is no MusicalPosition - MusicalDuration in Chapter 3's type algebra (only Duration - Duration and Position - Position -> Duration) because an unclamped result could go negative, which is not a valid position. retreat_position reimplements it directly over the raw RationalTime, clamping at MusicalPosition::origin().

set_entry_duration's check order mirrors set_selection_duration's existing precedent: duration positivity (InvalidDuration) is checked before caret existence (NoCaret) — a malformed argument is rejected independent of session state.

Tests and mutations (all substituted, observed failing, then reversed — never git checkout): t1 caret_advances_across_a_would_be_barline (mutation: skip the advance in enter_at_caretleft: MusicalPosition (3/4) vs right: MusicalPosition(1/1), dies). t2 octave_inference_table + octave_tie_break_prefers_downward + enter_nominal_infers_octave_from_the_nearest_preceding_note (mutation: flip nearer_is_down's <= to < → kills octave_tie_break_prefers_downward only, confirming by direct observation that octave_inference_table is insensitive to it — the tie-break's real unreachability, not a testing gap). t3 enter_pitch_reproduces_insert_note_at_s_overwrite — twin sessions from the same seed, one via insert_note_at, one via the caret at the same voice/position/duration/pitch, asserted byte-identical (assert_eq!(session_a.score(), session_b.score())) rather than just field-by-field, since both sessions mint fresh ids deterministically from identical starting state (mutation: skip make_room_ops, insert directly → not a reducer refusal — apply returns Ok, but the reducer's own overlap precondition silently no-ops the bare InsertEvent against the already-occupied slot, so session_b's score is simply unchanged from before the edit while session_a's carries the overwrite; the assertion catches the value mismatch, confirmed by inspecting both failing Score dumps). t4 midi_note_to_pitch_table (mutation: note/12 instead of note/12 - 1A0 becomes A1, dies). t5 x_at_position_round_trips_with_position_at + forward_x_extrapolates_from_the_last_segment_not_the_first — the "wrong segment" mutation (n - 20 in forward_x's past-the-last- anchor branch) does not kill the round-trip test: valid_score's onsets are uniformly time-spaced and the stub renders them uniformly in x, so every segment shares one slope and segment 0 vs. the true last segment are indistinguishable there — an honest finding, not swept under the round-trip test's apparent coverage. A second, direct test calls forward_x with hand-built, deliberately non-uniform anchors (slopes 40 and 8 x-per-whole-note); the mutation there gives 30 instead of the correct 14, confirmed killed, then reverted. t6 undo_restores_the_score_but_leaves_the_caret_advanced (mutation: undo also retreats the caret by one entry duration → 1/1 vs 5/4, dies) and caret_clears_when_its_voice_is_deleted (mutation: empty out reresolve_caret's body → caret stays Some after DeleteVoice succeeds, dies).