From 2740a6c53ce55b34fad4f1257f2be777cef02fce Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 9 Jul 2026 15:46:43 -0400 Subject: [PATCH] TransposeInterval: the faithful transpose, and the frozen one it replaces Closes P12-K2. The reducer, the payload at wire discriminant 30, and the editor authoring that emits it. TransposeIntervalOp carries targets: CanonicalSet -- a set at the type level, not a Vec plus a dedup() someone can forget. PitchId's Ord is its canonical byte order, so a BTreeSet iterates in canonical order and cannot hold a duplicate. Encoding it is the wire table's seq-strictly-increasing by construction. The frozen Transpose keeps sorted_canonical and its multiset. Reduction refuses atomically. Every mutable target is resolved before any is written, so an untransposable one leaves the whole chord alone -- a chord transposed except for one note is a different chord. Tombstoned and SYSTEM_DERIVED targets are still skipped: a deleted pitch is not an untransposable pitch, it is one the operation has nothing to say about. The three refusals map to PitchSpaceMismatch (6, un-reserved -- detecting a non-Cmn position reads a discriminant, never the tuning catalog its doc claimed to need), AcousticRealizationPinned (14), TranspositionOutOfRange (15). The refusal reads pitch values, which exist only under reduce_onto, so it is a graph-aware-only precondition that passes base-free -- the convention modify_identified_pitch's system-derived check already set. It writes nothing base-free either, so both modes agree on objects, and on the effect log for every operation whose targets are all transposable, which is all base-free reduction can see. Spelling propagates. Core Ch2 requires transposing operations to produce Propagated attachments; Transpose produced none, so an authored spelling survived a transposition still pinned to the notehead it was written against. simplest_spelling on a Cmn position returns the authored letter verbatim, so the attachment carries exactly what the interval's diatonic component decided: a diminished sixth up from C4 records A-double-flat, not the enharmonic G. Editor. transpose_selection now takes a TranspositionInterval; a scalar cannot tell "up an octave" (7,12) from "C with twelve sharps" (0,12), which is P12-K2 itself. The "+1 semitone" key became alter_selection(+-1). TransposeOp is now unused in editor-core's lib, so the compiler enforces "never authored". Tests, five mutations verified: the graph write removed; the refusal made non-atomic (skip the offender, move the rest); spelling propagation dropped; and -- for the freeze -- graph_transpose_pitch "helpfully" repaired to use the real algebra, which the_frozen_transpose_keeps_its_saturating_alteration_ semantics correctly rejects. That test guards against rewriting history, not against a bug. The two old transpose tests were false locks, but the fix was not to rewrite them as the design gate promised. What they assert -- skip-tombstoned, skip-system-derived, refuse-missing -- are effect-log properties, correctly checked base-free. The defect was one test's NAME: it claimed the live target "shifts" and checked nothing of the kind. Renamed to say what it proves; the shift itself is now locked by two graph-aware tests against reduce_onto. fuzz::gen_payload gained arm 27, so below(27) became below(28) and the seeded stream reshuffled; the canonical-base digest is re-pinned consciously, per that test's own instruction and the Phase-D precedent. Nothing leaked -- canonical_bytes embeds effects, conflicts and anomalies, never payload values. The frozen Transpose keeps fuzz arm 6 and its testkit corpus authoring: it must reduce correctly forever, and a generator is now the only thing that will ever produce one. Gate: fmt clean, clippy 0, 30 targets / 982 passed / 0 failed, docs 0 under -D warnings, conformance 8/8, zero golden churn. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-core/src/graph.rs | 2 +- crates/epiphany-editor-core/src/barriers.rs | 8 + crates/epiphany-editor-core/src/lib.rs | 167 ++++-- crates/epiphany-editor-gui/src/main.rs | 9 +- crates/epiphany-ops/DECISIONS.md | 46 +- crates/epiphany-ops/src/effect.rs | 21 +- crates/epiphany-ops/src/fuzz.rs | 19 +- crates/epiphany-ops/src/lib.rs | 2 +- crates/epiphany-ops/src/migrate.rs | 3 + crates/epiphany-ops/src/payload.rs | 75 ++- crates/epiphany-ops/src/reduce.rs | 594 +++++++++++++++++++- crates/epiphany-ops/src/v0.rs | 3 + 12 files changed, 877 insertions(+), 72 deletions(-) diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 95a31a7..3c984f5 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -1561,7 +1561,7 @@ pub struct Instrument { /// B-flat clarinet is `-1` diatonic, `-2` chromatic). Schema major 2 /// (appended; migration default `None`). /// - /// Its *action* on a pitch is pinned by [`Pitch::transposed`] + /// Its *action* on a pitch is pinned by [`crate::Pitch::transposed`] /// (`req:pitch:transposition`). What is still unimplemented is its /// *automatic application* at the written/sounding boundary: nothing here /// respells a written part into a sounding one, or resolves either to a diff --git a/crates/epiphany-editor-core/src/barriers.rs b/crates/epiphany-editor-core/src/barriers.rs index ea19d67..a3a07b3 100644 --- a/crates/epiphany-editor-core/src/barriers.rs +++ b/crates/epiphany-editor-core/src/barriers.rs @@ -345,6 +345,14 @@ pub(crate) fn subjects_of(kind: &OperationKind, score: &Score) -> BarrierSubject .map(|pitch| (TypedObjectId::Pitch(*pitch), pitch_context(score, *pitch))) .collect(), ), + // Same subjects as the frozen `Transpose`: the pitches it names. The + // set iterates in canonical order, so the subject list is canonical. + OperationKind::TransposeInterval(op) => BarrierSubjects::Objects( + op.targets + .iter() + .map(|pitch| (TypedObjectId::Pitch(*pitch), pitch_context(score, *pitch))) + .collect(), + ), OperationKind::InsertIdentifiedPitch(op) => { // The op mutates the host event's pitch list *and* mints the pitch: // both are named targets. diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index de44ac2..41c810c 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -59,8 +59,8 @@ use epiphany_core::{ PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, - TimeSignature, TimeSignatureDisplay, TransactionId, TuningReference, TupletId, TypedObjectId, - VoiceId, WallClockTime, + 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, @@ -73,7 +73,7 @@ use epiphany_ops::{ DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationKindTag, OperationPayload, OperationSet, OperationStamp, RespellPitchOp, TransactionCategory, - TransactionDescriptor, TransposeOp, TupletCompensation, + TransactionDescriptor, TransposeIntervalOp, TupletCompensation, }; /// The current selection: the score-graph object to act on, plus the stable layout @@ -1306,22 +1306,43 @@ impl EditorSession { TransactionId::new(self.replica, next) } - /// Transposes the selected pitch by `chromatic_steps` (a `+1` is a sharpen). - /// Errors if nothing — or a non-pitch — is selected. + /// Transposes the selected pitch by `interval`. Errors if nothing — or a + /// non-pitch — is selected. 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`. + /// + /// Authors `TransposeInterval`. The frozen `Transpose` is never emitted by + /// new authoring (`req:opcat:transpose-frozen`). pub fn transpose_selection( &mut self, - chromatic_steps: i32, + interval: TranspositionInterval, ) -> Result { let selection = self.selection.ok_or(EditorError::NoSelection)?; let TypedObjectId::Pitch(pitch) = selection.source else { return Err(EditorError::WrongSelection { expected: "pitch" }); }; - self.apply(OperationKind::Transpose(TransposeOp { - targets: vec![pitch], - chromatic_steps, + self.apply(OperationKind::TransposeInterval(TransposeIntervalOp { + targets: [pitch].into_iter().collect(), + interval, })) } + /// Sharpens (`+1`) or flattens (`-1`) the selected pitch: a chromatic + /// alteration with no diatonic motion, so the notehead keeps its staff line + /// and only the accidental changes. This is what a "+1 semitone" key does. + /// + /// 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). + pub fn alter_selection(&mut self, semitones: i32) -> Result { + self.transpose_selection(TranspositionInterval { + diatonic_steps: 0, + chromatic_steps: semitones, + }) + } + /// Deletes the 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. @@ -2624,10 +2645,13 @@ fn staff_start_clefs(logical: &LogicalLayoutIR) -> StartClefs { 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") @@ -2719,7 +2743,7 @@ mod tests { 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.transpose_selection(1).is_err()); + assert!(session.alter_selection(1).is_err()); } /// A pitch in the last event of the first voice that has events — its slot has @@ -3063,7 +3087,7 @@ mod tests { for (onset, pid) in events.iter().skip(1) { let mut session = open_rich(0x5EED); select_pitch(&mut session, *pid); - if session.transpose_selection(1).is_err() { + if session.alter_selection(1).is_err() { continue; } let glyph_x = |synthesized: bool| { @@ -4241,7 +4265,7 @@ mod tests { // 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.transpose_selection(1).expect("the sharpen applies"); + let outcome = session.alter_selection(1).expect("the sharpen applies"); assert!(outcome.graph_changed, "the edit reduced onto the graph"); assert!( @@ -4260,10 +4284,7 @@ mod tests { fn transpose_requires_a_pitch_selection() { let mut session = open_rich(0x5EED); // Nothing selected. - assert_eq!( - session.transpose_selection(1), - Err(EditorError::NoSelection) - ); + 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() @@ -4274,7 +4295,7 @@ mod tests { if let Some(id) = non_pitch { session.select(id); assert_eq!( - session.transpose_selection(1), + session.alter_selection(1), Err(EditorError::WrongSelection { expected: "pitch" }) ); } @@ -4285,9 +4306,9 @@ mod tests { // Distinct minted op ids, so the second edit is not deduplicated. let mut session = open_rich(0x5EED); click_a_notehead(&mut session); - assert!(session.transpose_selection(1).unwrap().graph_changed); + assert!(session.alter_selection(1).unwrap().graph_changed); let mid = session.score().clone(); - assert!(session.transpose_selection(1).unwrap().graph_changed); + assert!(session.alter_selection(1).unwrap().graph_changed); assert_ne!( &mid, session.score(), @@ -5217,8 +5238,8 @@ mod tests { assert!(session.last_applied().is_none()); click_a_notehead(&mut session); - session.transpose_selection(1).unwrap(); - session.transpose_selection(-1).unwrap(); + 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. @@ -5226,13 +5247,81 @@ mod tests { 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. + // 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::Transpose(_)) + 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_and_redo_a_transpose() { let mut session = open_rich(0x5EED); @@ -5241,7 +5330,7 @@ mod tests { panic!("a notehead selects a pitch"); }; let before = session.current_pitch(pid).unwrap(); - session.transpose_selection(1).expect("sharpen"); + session.alter_selection(1).expect("sharpen"); let after = session.current_pitch(pid).unwrap(); assert_ne!(before, after); assert!(session.can_undo() && !session.can_redo()); @@ -5411,14 +5500,14 @@ mod tests { assert!(session.redo().is_none(), "nothing to redo"); click_a_notehead(&mut session); - session.transpose_selection(1).expect("e1"); - session.transpose_selection(1).expect("e2"); + 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.transpose_selection(-1).expect("e3"); + session.alter_selection(-1).expect("e3"); assert!(!session.can_redo(), "the new edit forked away the redo"); assert_eq!( session.applied_operations().len(), @@ -5431,10 +5520,10 @@ mod tests { fn a_fork_mints_a_fresh_operation_id_not_the_undone_one() { let mut session = open_rich(0x5EED); click_a_notehead(&mut session); - session.transpose_selection(1).expect("A"); + session.alter_selection(1).expect("A"); let a_id = session.last_applied().unwrap().id; session.undo().expect("undo A"); - session.transpose_selection(-1).expect("B (forks 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"); @@ -5493,15 +5582,15 @@ mod tests { fn edits_after_a_fork_re_reduce_cleanly() { let mut session = open_rich(0x5EED); click_a_notehead(&mut session); - session.transpose_selection(1).expect("A"); - session.transpose_selection(1).expect("B"); + 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.transpose_selection(-1).expect("C"); - session.transpose_selection(-1).expect("D"); + 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"); @@ -5523,7 +5612,7 @@ mod tests { fn with_identity_is_refused_after_an_undone_edit() { let mut session = open_rich(0x5EED); click_a_notehead(&mut session); - session.transpose_selection(1).expect("edit"); + session.alter_selection(1).expect("edit"); session.undo().expect("undo"); assert!( session.applied_operations().is_empty(), @@ -5542,8 +5631,8 @@ mod tests { // conflicts — both here and on a peer replaying the log. let mut session = open_rich(0x5EED); click_a_notehead(&mut session); - session.transpose_selection(1).unwrap(); - session.transpose_selection(1).unwrap(); + session.alter_selection(1).unwrap(); + session.alter_selection(1).unwrap(); let log = session.applied_operations(); assert_eq!(log.len(), 2); @@ -5575,7 +5664,7 @@ mod tests { // `(new_replica, 0)` hole. The session refuses it. let mut session = open_rich(0x5EED); click_a_notehead(&mut session); - session.transpose_selection(1).unwrap(); + session.alter_selection(1).unwrap(); let _ = session.with_identity(ReplicaId(2), AuthorId(0)); } @@ -5585,7 +5674,7 @@ mod tests { let mut session = open_rich(0x5EED).with_identity(ReplicaId::SYSTEM_DERIVED, AuthorId(0)); click_a_notehead(&mut session); assert_eq!( - session.transpose_selection(1), + session.alter_selection(1), Err(EditorError::RejectedOperation) ); assert!( @@ -5608,7 +5697,7 @@ mod tests { // not even the operation counter — mutates, so a later valid edit still // works. assert_eq!( - session.transpose_selection(1), + session.alter_selection(1), Err(EditorError::RejectedOperation) ); assert_eq!( diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index c3143d9..e591157 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -128,6 +128,7 @@ fn payload_label(payload: &OperationPayload) -> &'static str { match payload { OperationPayload::Primitive(kind) => match kind { OperationKind::Transpose(_) => "Transpose", + OperationKind::TransposeInterval(_) => "TransposeInterval", OperationKind::ModifyIdentifiedPitch(_) => "ModifyIdentifiedPitch", OperationKind::DeleteIdentifiedPitch(_) => "DeleteIdentifiedPitch", OperationKind::DeleteEvent(_) => "DeleteEvent", @@ -281,10 +282,10 @@ impl EditorApp { self.run("delete", |s| s.delete_selection()); } if ui.button("Transpose ♯").clicked() { - self.run("transpose +1", |s| s.transpose_selection(1)); + self.run("transpose +1", |s| s.alter_selection(1)); } if ui.button("Transpose ♭").clicked() { - self.run("transpose -1", |s| s.transpose_selection(-1)); + self.run("transpose -1", |s| s.alter_selection(-1)); } if ui.button("Move ↑").clicked() { self.run("move up", |s| s.move_selection_staff_step(1)); @@ -399,10 +400,10 @@ impl EditorApp { self.run("move down", |s| s.move_selection_staff_step(-1)); } if k.sharp { - self.run("transpose +1", |s| s.transpose_selection(1)); + self.run("transpose +1", |s| s.alter_selection(1)); } if k.flat { - self.run("transpose -1", |s| s.transpose_selection(-1)); + self.run("transpose -1", |s| s.alter_selection(-1)); } if k.add { self.run("add chord note", |s| s.add_note_to_selection()); diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 1145019..cfa1371 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -1276,5 +1276,47 @@ gutting `graph_transpose_pitch` entirely leaves `transpose_skips_system_derived_targets_p12_k3` both green. They call base-free `reduce()`, where `graph` is `None` and the function never runs, and they assert only `OperationEffect`. Only `editor-core`'s `undo_and_redo_a_transpose` -— three crates away — catches it. Both are rewritten against `reduce_onto()` -with asserted pitch values as part of the implementation. +— three crates away — catches it. + +The fix was *not* to rewrite them, which the design gate wrongly promised. What +they assert — skip-tombstoned, skip-system-derived, refuse-missing — are +**effect-log** properties, and base-free reduction is the right place to assert +them. The defect was the first one's *name*: it claimed to check that the live +target "shifts" and checked no such thing. So: + +- renamed to `transpose_effect_log_skips_tombstoned_targets_and_refuses_a_missing_one`, + with its scope written into the test; +- `the_frozen_transpose_skips_a_tombstoned_target_and_shifts_the_live_sibling` + now makes the original claim, against `reduce_onto` and an asserted pitch; +- `the_frozen_transpose_keeps_its_saturating_alteration_semantics` locks the + three pinned defects (octave-blind, saturating, multiset) as replay semantics. + Mutation-verified by "helpfully" repairing `graph_transpose_pitch` to use the + real algebra: the test fails, which is the point — it is a guard against + rewriting history, not against a bug. + +**Consequences elsewhere.** + +- `EditorSession::transpose_selection` now takes a `TranspositionInterval`. A + scalar cannot distinguish "up an octave" `(7, 12)` from "C with twelve + sharps" `(0, 12)`, which *is* P12-K2. The "+1 semitone" key became + `alter_selection(±1)` — a chromatic alteration with no diatonic motion, which + is what that key always meant and what the old operation always did. + `TransposeOp` is consequently unused in `editor-core`'s lib: the compiler now + enforces "never authored". +- `fuzz::gen_payload` gained arm 27 and `below(27)` became `below(28)`, which + reshuffles the seeded stream, so `the_canonical_base_is_byte_identical_...` + was re-pinned. Consciously, per that test's own instruction, and for the same + reason as the Phase-D re-pin. Nothing leaked: `canonical_bytes` embeds + effects, conflicts, and anomalies, never payload values. +- The frozen `Transpose` keeps its fuzz coverage (arm 6) and its corpus + authoring in `testkit`. It must reduce correctly forever, and a generator is + now the only thing that will ever produce one. + +**Base-free reduction.** The refusal reads pitch *values*, which exist only +under `reduce_onto`. It is therefore a graph-aware-only precondition that +passes base-free — the convention `modify_identified_pitch`'s system-derived +rewrite check already established and documents ("unverifiable and passes, like +the other graph-aware-only preconditions"). It also *writes* nothing base-free, +so the two modes agree on `objects`, and on the effect log for every operation +whose targets are all transposable — the only case base-free reduction can +distinguish. diff --git a/crates/epiphany-ops/src/effect.rs b/crates/epiphany-ops/src/effect.rs index cea848a..db5130d 100644 --- a/crates/epiphany-ops/src/effect.rs +++ b/crates/epiphany-ops/src/effect.rs @@ -143,8 +143,13 @@ pub enum PreconditionFailureReason { PositionOutsideRegion, /// A pitch-space or tuning-context precondition failed. /// - /// Reserved: producing this requires the Chapter 4 tuning catalog - /// (pitch-space registry), which is deferred Track-C work. + /// Produced by `TransposeInterval` against a target whose + /// `scale_position.position` is not `Cmn`, so the interval's diatonic + /// component has no nominal to move (operation_catalog + /// §TransposeInterval). This was once documented as reserved pending the + /// Chapter 4 tuning catalog; that was an error — detecting a non-`Cmn` + /// position reads a discriminant, not a pitch-space registry. A genuine + /// tuning-context precondition would also land here. PitchSpaceMismatch, /// The operation targeted a voice that does not exist or is tombstoned. VoiceMissing, @@ -171,6 +176,15 @@ pub enum PreconditionFailureReason { /// content disagrees (Pass 12, P12-K9 — replaces the `TargetMissing` /// misnomer at the value-retaining re-create sites). RecreateContentMismatch, + /// A `TransposeInterval` target's `AcousticRealization::AbsoluteHz` + /// overrides the tuning system (Push 4a; operation_catalog + /// §TransposeInterval). Moving its scale position would move the notehead + /// without moving the sound, so the operation refuses instead. + AcousticRealizationPinned, + /// A `TransposeInterval` would drive a target's `alteration` or `octave` + /// past its `i8` bound (Push 4a). The frozen `Transpose` saturates here + /// and reports success; this refuses. + TranspositionOutOfRange, } impl PreconditionFailureReason { @@ -193,6 +207,9 @@ impl PreconditionFailureReason { // Additive (Pass-12 G-pass, P12-K3/P12-K9); appended past 11. PreconditionFailureReason::SystemDerivedContentImmutable => 12, PreconditionFailureReason::RecreateContentMismatch => 13, + // Additive (Push 4a, TransposeInterval); appended past 13. + PreconditionFailureReason::AcousticRealizationPinned => 14, + PreconditionFailureReason::TranspositionOutOfRange => 15, } } } diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 538377e..649ddbe 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -22,7 +22,8 @@ use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId, - RepeatStructureId, ReplicaId, SlurId, StaffId, StaffInstanceId, TypedObjectId, VoiceId, + RepeatStructureId, ReplicaId, SlurId, StaffId, StaffInstanceId, TranspositionInterval, + TypedObjectId, VoiceId, }; use epiphany_determinism::fuzz::SplitMix64; @@ -36,7 +37,7 @@ use crate::payload::{ DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, - SetUserSystemBreakOp, TransposeOp, TupletCompensation, + SetUserSystemBreakOp, TransposeIntervalOp, TransposeOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; @@ -86,7 +87,7 @@ fn pitch(n: u64) -> PitchId { /// Generates a random payload over the shared id space. fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { - let kind = match rng.below(27) { + let kind = match rng.below(28) { 0 => { let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); @@ -268,6 +269,18 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { 26 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: RepeatStructureId::new(ReplicaId(7), rng.below(3)), }), + // Push 4a. The frozen `Transpose` (arm 6) keeps its own coverage: it + // must reduce correctly forever, and only a generator will ever author + // one again. + 27 => OperationKind::TransposeInterval(TransposeIntervalOp { + targets: (0..1 + rng.below(2)) + .map(|_| pitch(rng.below(ID_SPACE))) + .collect(), + interval: TranspositionInterval { + diatonic_steps: rng.below(5) as i32 - 2, + chromatic_steps: rng.below(9) as i32 - 4, + }, + }), _ => OperationKind::SetStaffLayout(SetStaffLayoutOp { staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), instrument_override: None, diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 0c09d1c..49e2ea4 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -124,7 +124,7 @@ pub use payload::{ OperationPayload, PositionRemapping, ResolveConflictPayload, ResolveEquivocationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, - TransactionDescriptor, TransposeOp, TupletCompensation, + TransactionDescriptor, TransposeIntervalOp, TransposeOp, TupletCompensation, }; pub use reduce::{ canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index c06a964..b4f62b3 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -177,6 +177,8 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind { V0OperationKind::CreateRepeatStructure(op.clone()) } OperationKind::DeleteRepeatStructure(op) => V0OperationKind::DeleteRepeatStructure(*op), + // Push 4a: born past v0; projected verbatim. + OperationKind::TransposeInterval(op) => V0OperationKind::TransposeInterval(op.clone()), } } @@ -328,6 +330,7 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result { OperationKind::CreateRepeatStructure(op.clone()) } + V0OperationKind::TransposeInterval(op) => OperationKind::TransposeInterval(op.clone()), V0OperationKind::DeleteRepeatStructure(op) => OperationKind::DeleteRepeatStructure(*op), }) } diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index f2d35f9..ab743e2 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -36,10 +36,12 @@ use epiphany_core::{ InstrumentId, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, Region, RegionId, RegionTimeModel, RepeatStructure, RepeatStructureId, Rest, ScoreMetadata, Slur, Spanner, Staff, StaffId, StaffInstance, StaffInstanceId, - StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, TimeSignature, TransactionId, TupletId, - TypedObjectId, Voice, VoiceId, + StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, TimeSignature, TransactionId, + TranspositionInterval, TupletId, TypedObjectId, Voice, VoiceId, +}; +use epiphany_determinism::{ + sorted_canonical, CanonicalDecode, CanonicalEncode, CanonicalSet, DecodeError, }; -use epiphany_determinism::{sorted_canonical, CanonicalDecode, CanonicalEncode, DecodeError}; use crate::conflict::{ConflictId, ResolutionAction}; use crate::encode::{push_canon, push_lp_bytes, push_seq, push_str, push_tag, push_u8_bool}; @@ -190,6 +192,9 @@ pub enum OperationKind { CreateRepeatStructure(CreateRepeatStructureOp), /// Tombstone a repeat structure (delete-wins, idempotent on re-delete). DeleteRepeatStructure(DeleteRepeatStructureOp), + /// Push 4a: the faithful transpose. Appended past 29 — a schema-minor + /// vocabulary append (`req:binfmt:kind-discriminants`). + TransposeInterval(TransposeIntervalOp), } impl OperationKind { @@ -279,6 +284,9 @@ impl OperationKind { // Schema-major-2 revision; appended past the Phase-3 24..=27. OperationKind::CreateRepeatStructure(_) => 28, OperationKind::DeleteRepeatStructure(_) => 29, + // Push 4a; appended past 29. Every constituent is a major-0 + // layout, so `schema_major` leaves it in the catch-all 0 arm. + OperationKind::TransposeInterval(_) => 30, } } @@ -319,6 +327,7 @@ impl OperationKind { // Name-verbatim projection, as the cross-cutting tags do. OperationKind::CreateRepeatStructure(_) => OperationKindTag::CreateRepeatStructure, OperationKind::DeleteRepeatStructure(_) => OperationKindTag::DeleteRepeatStructure, + OperationKind::TransposeInterval(_) => OperationKindTag::TransposeInterval, } } } @@ -340,6 +349,7 @@ impl CanonicalEncode for OperationKind { } OperationKind::ModifyEvent(op) => op.encode_canonical(out), OperationKind::Transpose(op) => op.encode_canonical(out), + OperationKind::TransposeInterval(op) => op.encode_canonical(out), OperationKind::InsertIdentifiedPitch(op) => op.encode_canonical(out), OperationKind::DeleteIdentifiedPitch(op) => op.encode_canonical(out), OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out), @@ -404,6 +414,8 @@ pub enum OperationKindTag { // cross-cutting tags are. CreateRepeatStructure, DeleteRepeatStructure, + /// Push 4a. + TransposeInterval, } impl OperationKindTag { @@ -441,6 +453,8 @@ impl OperationKindTag { // Schema-major-2 revision; appended past the Phase-3 24..=27. OperationKindTag::CreateRepeatStructure => 28, OperationKindTag::DeleteRepeatStructure => 29, + // Push 4a; appended past 29. + OperationKindTag::TransposeInterval => 30, } } } @@ -998,12 +1012,24 @@ impl CanonicalEncode for ModifyEventOp { } } -/// Transpose live pitches by a chromatic interval (Chapter 6 §6.10 Transpose). -/// Pitch identifiers are preserved; reduction is order-dependent in the general -/// case (interval composition need not commute). `chromatic_steps` is a minimal -/// interval (a CMN alteration shift) that, in this prototype, commutes except at -/// the alteration's `i8` saturation bound; rich interval algebra is deferred -/// (Chapter 4 tuning catalog; P12-K2). +/// **Frozen; replay only** (operation_catalog §Transpose, +/// `req:opcat:transpose-frozen`). New authoring emits [`TransposeIntervalOp`]. +/// +/// Shifts each live target's CMN `alteration` by `chromatic_steps`. Its +/// semantics are pinned exactly as they shipped, because an operation is +/// history: a replica replaying a stored `Transpose` must reconstruct the state +/// its author saw, and a "corrected" reduction rule would silently rewrite +/// every score that used one. The pinned defects, all normative for replay: +/// +/// * `targets` is a **multiset** — a target named twice is shifted twice. +/// * The nominal and octave never move: `C4` by `+12` becomes `C4` with +/// `alteration: 12` (six double-sharps), not `C5`. +/// * A shift past the `i8` bound **saturates silently** and still reports +/// `Applied`, so the operation is not invertible. +/// * A non-`Cmn` position is left unchanged and still reports `Applied`. +/// +/// [`TransposeIntervalOp`] repairs every one of these under a new discriminant +/// that no historical operation carries (Push 4a, closing P12-K2). #[derive(Clone, PartialEq, Eq, Debug)] pub struct TransposeOp { pub targets: Vec, @@ -1012,11 +1038,42 @@ pub struct TransposeOp { impl CanonicalEncode for TransposeOp { fn encode_canonical(&self, out: &mut Vec) { + // `sorted_canonical`, NOT a set: the multiset is frozen wire form. push_seq(out, &sorted_canonical(self.targets.clone())); out.extend_from_slice(&self.chromatic_steps.to_le_bytes()); } } +/// Transpose live pitches by a [`TranspositionInterval`] (operation_catalog +/// §TransposeInterval; Push 4a). The faithful replacement for the frozen +/// [`TransposeOp`]. +/// +/// `targets` is a genuine **set** at the type level — [`CanonicalSet`] is a +/// `BTreeSet`, and `PitchId`'s `Ord` *is* its canonical byte order, so it +/// iterates in canonical order and cannot hold a duplicate. A transposition is +/// a function of *which* pitches it names, never of how many times. +/// +/// Reduction refuses atomically rather than saturating: see +/// [`Pitch::transposed`](epiphany_core::Pitch::transposed) for the algebra and +/// the three refusal cases. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TransposeIntervalOp { + pub targets: CanonicalSet, + pub interval: TranspositionInterval, +} + +impl CanonicalEncode for TransposeIntervalOp { + fn encode_canonical(&self, out: &mut Vec) { + // A BTreeSet already iterates in canonical order (PitchId: Ord is its + // canonical byte order) and cannot repeat — the `seq^⇑` of the wire + // table, by construction rather than by a call someone can forget. + let targets: Vec = self.targets.iter().copied().collect(); + push_seq(out, &targets); + out.extend_from_slice(&self.interval.diatonic_steps.to_le_bytes()); + out.extend_from_slice(&self.interval.chromatic_steps.to_le_bytes()); + } +} + /// Add a pitch to a live event (Chapter 6 §6.10 InsertIdentifiedPitch). Carries /// the full [`IdentifiedPitch`] (v1). Mint discipline. #[derive(Clone, PartialEq, Eq, Debug)] diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 36e2d3d..a0a5e9e 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -32,14 +32,14 @@ use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use epiphany_core::{ - canonical_pitch_bytes, derive_promoted_voice_id, AnchorOffset, AnnotationAnchor, - CanonicalValue, Event, EventDuration, EventId, EventPosition, GestureAnchoring, InstrumentId, - MeterChange, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, - PitchSpelling, RationalTime, RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, - ScoreMetadata, SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource, Staff, - StaffId, StaffInstance, StaffInstanceId, StaffLineConfiguration, TempoMap, TempoSegment, - TempoShape, TimeAnchor, TimeSignature, TimeSignatureId, TransactionId, TypedObjectId, Voice, - VoiceId, VoiceOrigin, + canonical_pitch_bytes, derive_promoted_voice_id, simplest_spelling, AnchorOffset, + AnnotationAnchor, CanonicalValue, Event, EventDuration, EventId, EventPosition, + GestureAnchoring, InstrumentId, MeterChange, MetricGrid, MusicalDuration, MusicalPosition, + OperationId, Pitch, PitchId, PitchSpelling, RationalTime, RegionEdge, RegionId, + RegionTimeModel, ReplicaId, Score, ScoreMetadata, SpellingAttachment, SpellingDirective, + SpellingScope, SpellingSource, Staff, StaffId, StaffInstance, StaffInstanceId, + StaffLineConfiguration, TempoMap, TempoSegment, TempoShape, TimeAnchor, TimeSignature, + TimeSignatureId, TransactionId, TransposeRefusal, TypedObjectId, Voice, VoiceId, VoiceOrigin, }; use epiphany_determinism::CanonicalEncode; @@ -61,7 +61,7 @@ use crate::payload::{ DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, - SetTimeSignatureOp, SetUserPageBreakOp, TransposeOp, TupletCompensation, + SetTimeSignatureOp, SetUserPageBreakOp, TransposeIntervalOp, TransposeOp, TupletCompensation, }; use crate::stamp::StampTuple; use crate::support::{ObjectKind, SerializedCanonicalInputs}; @@ -2502,6 +2502,7 @@ impl<'a> Reducer<'a> { OperationKind::Registered(_, _) => OperationEffect::Applied, OperationKind::ModifyEvent(op) => self.modify_event(env, op), OperationKind::Transpose(op) => self.transpose(env, op), + OperationKind::TransposeInterval(op) => self.transpose_interval(env, op), OperationKind::InsertIdentifiedPitch(op) => self.insert_identified_pitch(env, op), OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op), OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op), @@ -5540,6 +5541,164 @@ impl<'a> Reducer<'a> { OperationEffect::Applied } + /// The faithful transpose (operation_catalog §TransposeInterval; Push 4a, + /// closing P12-K2). + /// + /// Shares [`Self::transpose`]'s target discipline: a target that never + /// entered canonical state refuses the whole operation; tombstoned and + /// `SYSTEM_DERIVED` targets are *skipped*. A deleted pitch is not an + /// untransposable pitch — it is one the operation has nothing to say about. + /// + /// Then it adds atomic refusal. Every remaining live target must be + /// transposable under `req:pitch:transposition`, or **nothing moves**: a + /// chord transposed except for one note is a different chord. + /// + /// Like the other graph-aware-only preconditions (see + /// [`Self::modify_identified_pitch`]'s system-derived rewrite check), the + /// refusal is unverifiable under base-free `reduce()` — no pitch values + /// exist there — and passes. It also writes nothing there, so the two modes + /// agree on `objects`, and on the effect log for every operation whose + /// targets are all transposable, which is the only case base-free reduction + /// can distinguish. + fn transpose_interval( + &mut self, + _env: &OperationEnvelope, + op: &TransposeIntervalOp, + ) -> OperationEffect { + if op + .targets + .iter() + .any(|pitch| !self.objects.contains_key(&TypedObjectId::Pitch(*pitch))) + { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + let live: Vec = op + .targets + .iter() + .copied() + .filter(|pitch| { + matches!( + self.objects.get(&TypedObjectId::Pitch(*pitch)), + Some(ObjectState::Live) + ) + }) + .collect(); + if live.is_empty() { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + }; + } + let mutable: Vec = live + .into_iter() + .filter(|pitch| pitch.replica() != ReplicaId::SYSTEM_DERIVED) + .collect(); + if mutable.is_empty() { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::SystemDerivedContentImmutable, + }, + }; + } + + // Atomicity: resolve every target's new value BEFORE writing any of + // them. `targets` is a set, so `mutable` is in canonical order, and the + // reported failure is the canonically-first offender — + // `req:opcat:transpose-interval-atomic`. + let mut resolved: Vec<(PitchId, Pitch)> = Vec::with_capacity(mutable.len()); + for pitch in &mutable { + let Some(current) = self.graph_pitch_value(*pitch) else { + continue; // base-free: no value to check, and none to write + }; + match current.transposed(op.interval) { + Ok(next) => resolved.push((*pitch, next)), + Err(refusal) => { + let reason = match refusal { + TransposeRefusal::NonCmnPosition => { + PreconditionFailureReason::PitchSpaceMismatch + } + TransposeRefusal::AcousticPinned => { + PreconditionFailureReason::AcousticRealizationPinned + } + TransposeRefusal::OutOfRange => { + PreconditionFailureReason::TranspositionOutOfRange + } + }; + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + }; + } + } + } + + for (pitch, value) in resolved { + self.graph_modify_pitch(pitch, &value); + self.graph_propagate_spelling(pitch, &value); + } + OperationEffect::Applied + } + + /// The current value of a live embedded pitch, or `None` under base-free + /// reduction (no graph) or if the pitch is not embedded in any event. + fn graph_pitch_value(&self, pitch: PitchId) -> Option { + let score = self.graph.as_ref()?; + let event = Self::graph_event_of_pitch(score, pitch)?; + match score.events.get(event) { + Some(Event::Pitched(pe)) => pe + .pitches + .iter() + .find(|ip| ip.id == pitch) + .map(|ip| ip.pitch.clone()), + _ => None, + } + } + + /// Records the spelling a transposition determined + /// (`req:opcat:transpose-interval-spelling`; core Ch2 §Spelling Sources: + /// "editing operations that move or transpose a pitch MUST produce + /// attachments with source `Propagated`"). + /// + /// Without this the spelling pre-pass re-infers a spelling for the moved + /// pitch, and any authored `UserChosen` attachment stays pinned to the + /// notehead it was written against — a deliberately-spelled C# transposed + /// up a fifth, re-inferred as Ab against the author's wish. The interval's + /// diatonic component already chose the nominal; `simplest_spelling` on a + /// `Cmn` position returns that authored letter verbatim (it infers only for + /// non-`Cmn` positions, which this operation refuses), so the attachment + /// carries exactly the interval's determination. + fn graph_propagate_spelling(&mut self, pitch: PitchId, value: &Pitch) { + let Some(spelling) = simplest_spelling(value) else { + return; + }; + let Some(score) = self.graph.as_mut() else { + return; + }; + // Upsert the propagated attachment (one per pitch), keeping the + // attachment list's canonical order stable for the resolver's + // tie-break — the same discipline `graph_respell_pitch` follows. + if let Some(existing) = score.spelling_attachments.iter_mut().find(|a| { + a.layer.is_none() + && matches!(a.source, SpellingSource::Propagated { .. }) + && matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch) + && matches!(a.directive, SpellingDirective::Explicit(_)) + }) { + existing.directive = SpellingDirective::Explicit(spelling); + } else { + score.spelling_attachments.push(SpellingAttachment { + scope: SpellingScope::Pitch(pitch), + directive: SpellingDirective::Explicit(spelling), + // Identifiers are preserved by transposition, so the pitch the + // spelling propagated *from* is the pitch it is attached to. + source: SpellingSource::Propagated { from: pitch }, + priority: 0, + layer: None, + }); + } + } + fn insert_identified_pitch( &mut self, env: &OperationEnvelope, @@ -8774,10 +8933,416 @@ mod tests { env } + use epiphany_core::{CmnNominal, TranspositionInterval}; + + // --- Push 4a: transpose test harness. ------------------------------------ + // + // The two `transpose_*` tests below were FALSE LOCKS until Push 4a: they + // reduced base-free, where `graph` is `None` and `graph_transpose_pitch` + // never runs, and they asserted only `OperationEffect`. Gutting the + // transpose entirely left both green. They now reduce ONTO a base and + // assert the pitch value, which is the thing the operation exists to change. + + /// Every `(event, pitch)` in canonical id order — `events.iter()` walks a + /// slotmap, whose order is an implementation detail. + fn pitch_ids(score: &Score) -> Vec { + let mut pairs: Vec<(EventId, PitchId)> = Vec::new(); + for event in score.events.iter() { + let mut ips = Vec::new(); + event.collect_identified_pitches(&mut ips); + pairs.extend(ips.iter().map(|ip| (event.id(), ip.id))); + } + pairs.sort(); + pairs.into_iter().map(|(_, p)| p).collect() + } + + fn event_of(score: &Score, pitch: PitchId) -> EventId { + score + .events + .iter() + .find_map(|event| { + let mut ips = Vec::new(); + event.collect_identified_pitches(&mut ips); + ips.iter().any(|ip| ip.id == pitch).then(|| event.id()) + }) + .expect("pitch is embedded in this score") + } + + fn set_pitch(score: &mut Score, pitch: PitchId, content: Pitch) { + let eid = event_of(score, pitch); + if let Some(Event::Pitched(pe)) = score.events.get_mut(eid) { + if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) { + ip.pitch = content; + return; + } + } + panic!("pitch {pitch:?} is not in a pitched event"); + } + + fn pitch_of(score: &Score, pitch: PitchId) -> Pitch { + let eid = event_of(score, pitch); + match score.events.get(eid) { + Some(Event::Pitched(pe)) => pe + .pitches + .iter() + .find(|ip| ip.id == pitch) + .map(|ip| ip.pitch.clone()) + .expect("pitch present"), + _ => panic!("not a pitched event"), + } + } + + /// A base score whose canonically-first pitch has the given content. + fn base_with_pitch(content: Pitch) -> (Score, PitchId) { + let mut base = epiphany_core::generators::valid_score(0x5EED); + let pid = *pitch_ids(&base).first().expect("the fixture has a pitch"); + set_pitch(&mut base, pid, content); + (base, pid) + } + + fn cmn_pitch(nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { + Pitch { + scale_position: epiphany_core::ScalePosition { + space: epiphany_core::PitchSpaceId::new("cmn-12"), + position: epiphany_core::PitchSpacePosition::Cmn { + nominal, + alteration, + octave, + }, + }, + acoustic: epiphany_core::AcousticPitch { + tuning: epiphany_core::TuningReference::Inherit, + realization: epiphany_core::AcousticRealization::Implicit, + }, + } + } + + fn cmn_of(p: &Pitch) -> (CmnNominal, i8, i8) { + match p.scale_position.position { + epiphany_core::PitchSpacePosition::Cmn { + nominal, + alteration, + octave, + } => (nominal, alteration, octave), + ref other => panic!("expected Cmn, got {other:?}"), + } + } + + fn interval(diatonic_steps: i32, chromatic_steps: i32) -> TranspositionInterval { + TranspositionInterval { + diatonic_steps, + chromatic_steps, + } + } + + /// Reduces one `TransposeInterval` onto `base` and returns (effect, score). + fn run_transpose( + base: &Score, + targets: &[PitchId], + iv: TranspositionInterval, + ) -> (OperationEffect, Score) { + let env = prim_env( + 3, + 0, + 30, + CausalContext::new(), + OperationKind::TransposeInterval(TransposeIntervalOp { + targets: targets.iter().copied().collect(), + interval: iv, + }), + ); + let id = env.id; + let mut set = OperationSet::new(); + set.accept_all(vec![env]); + let out = set.reduce_onto(base); + let effect = out + .state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff.clone()) + .expect("effect recorded"); + (effect, out.score) + } + #[test] - fn transpose_skips_tombstoned_targets_and_shifts_the_live_ones() { + fn transpose_interval_moves_the_octave_not_the_alteration() { + // The P12-K2 defect at the operation layer: the frozen `Transpose` by + // +12 produced `alteration: 12` on the same C4. + let (base, pid) = base_with_pitch(cmn_pitch(CmnNominal::C, 0, 4)); + let (effect, score) = run_transpose(&base, &[pid], interval(7, 12)); + assert_eq!(effect, OperationEffect::Applied); + assert_eq!(cmn_of(&pitch_of(&score, pid)), (CmnNominal::C, 0, 5)); + } + + #[test] + fn transpose_interval_targets_are_a_set_so_a_duplicate_cannot_double_shift() { + // The frozen `Transpose` shifts a duplicated target twice. The set type + // makes that unrepresentable: both spellings of the payload build the + // same op, so they also hash the same. + let (base, pid) = base_with_pitch(cmn_pitch(CmnNominal::C, 0, 4)); + let once = TransposeIntervalOp { + targets: [pid].into_iter().collect(), + interval: interval(0, 1), + }; + let twice = TransposeIntervalOp { + targets: [pid, pid].into_iter().collect(), + interval: interval(0, 1), + }; + assert_eq!(once, twice, "a duplicate target is not representable"); + + let (effect, score) = run_transpose(&base, &[pid, pid], interval(0, 1)); + assert_eq!(effect, OperationEffect::Applied); + // Shifted ONCE: C#4, not D4 (which is what two +1 shifts would give). + assert_eq!(cmn_of(&pitch_of(&score, pid)), (CmnNominal::C, 1, 4)); + } + + #[test] + fn transpose_interval_refuses_atomically_and_moves_no_sibling() { + // A two-pitch chord where one note is pinned to a frequency. Refusing + // the whole operation is the point: a chord transposed except for one + // note is a different chord. + let mut base = epiphany_core::generators::valid_score(0x5EED); + let pitches = pitch_ids(&base); + assert!(pitches.len() >= 2, "need two pitches"); + let (movable, pinned) = (pitches[0], pitches[1]); + + set_pitch(&mut base, movable, cmn_pitch(CmnNominal::C, 0, 4)); + let mut hz = cmn_pitch(CmnNominal::E, 0, 4); + hz.acoustic.realization = + epiphany_core::AcousticRealization::absolute_hz(329.6).expect("finite"); + set_pitch(&mut base, pinned, hz); + + let (effect, score) = run_transpose(&base, &[movable, pinned], interval(4, 7)); + assert_eq!( + effect, + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::AcousticRealizationPinned, + }, + } + ); + // The transposable sibling did NOT move. + assert_eq!(cmn_of(&pitch_of(&score, movable)), (CmnNominal::C, 0, 4)); + } + + #[test] + fn transpose_interval_refuses_a_non_cmn_target_and_an_overflow() { + let mut integer = cmn_pitch(CmnNominal::C, 0, 4); + integer.scale_position.position = epiphany_core::PitchSpacePosition::Integer { + space_size: 31, + index: 7, + }; + let expected = integer.scale_position.position.clone(); + let (base, pid) = base_with_pitch(integer); + let (effect, score) = run_transpose(&base, &[pid], interval(4, 7)); + assert_eq!( + effect, + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::PitchSpaceMismatch, + }, + } + ); + assert_eq!(pitch_of(&score, pid).scale_position.position, expected); + + // The frozen `Transpose` clamps here and reports Applied. + let (base, pid) = base_with_pitch(cmn_pitch(CmnNominal::C, 0, 127)); + let (effect, score) = run_transpose(&base, &[pid], interval(7, 12)); + assert_eq!( + effect, + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TranspositionOutOfRange, + }, + } + ); + assert_eq!(cmn_of(&pitch_of(&score, pid)), (CmnNominal::C, 0, 127)); + } + + #[test] + fn transpose_interval_propagates_the_spelling_it_determined() { + // req:opcat:transpose-interval-spelling. Core Ch2: "editing operations + // that move or transpose a pitch MUST produce attachments with source + // Propagated." The frozen Transpose produces none. + let (base, pid) = base_with_pitch(cmn_pitch(CmnNominal::C, 0, 4)); + assert!( + !base.spelling_attachments.iter().any(|a| matches!( + &a.scope, SpellingScope::Pitch(p) if *p == pid)), + "the fixture starts with no attachment on this pitch" + ); + + // A diminished sixth up from C4 is A-double-flat 4: same sound as G4, + // spelled a step away. Only the interval's diatonic component knows. + let (effect, score) = run_transpose(&base, &[pid], interval(5, 7)); + assert_eq!(effect, OperationEffect::Applied); + assert_eq!(cmn_of(&pitch_of(&score, pid)), (CmnNominal::A, -2, 4)); + + let attachment = score + .spelling_attachments + .iter() + .find(|a| matches!(&a.scope, SpellingScope::Pitch(p) if *p == pid)) + .expect("a transposition records its spelling"); + assert_eq!(attachment.source, SpellingSource::Propagated { from: pid }); + let SpellingDirective::Explicit(spelling) = &attachment.directive else { + panic!("an explicit spelling"); + }; + assert_eq!( + spelling.nominal, + epiphany_core::SpellingNominal::Cmn(CmnNominal::A) + ); + assert_eq!(spelling.octave, 4); + // Two flats, not a G-natural: the spelling follows the interval, not + // the simplest enharmonic re-inference. + assert_eq!(spelling.accidentals.len(), 1); + } + + #[test] + fn transpose_interval_skips_a_tombstoned_target_but_refuses_a_missing_one() { + // The skip/refuse distinction: a deleted pitch is not an untransposable + // pitch, it is one the operation has nothing to say about. + let (base, pid) = base_with_pitch(cmn_pitch(CmnNominal::C, 0, 4)); + let ghost = PitchId::new(ReplicaId(9), 999); + let (effect, score) = run_transpose(&base, &[pid, ghost], interval(0, 1)); + assert_eq!( + effect, + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + ); + assert_eq!(cmn_of(&pitch_of(&score, pid)), (CmnNominal::C, 0, 4)); + } + + #[test] + fn the_frozen_transpose_keeps_its_saturating_alteration_semantics() { + // req:opcat:transpose-frozen. These are DEFECTS, pinned as normative + // replay semantics: a stored Transpose must reconstruct the state its + // author saw. If this test ever "fails better", history has been + // rewritten. + let (base, pid) = base_with_pitch(cmn_pitch(CmnNominal::C, 0, 4)); + let run = |targets: Vec, steps: i32| -> Pitch { + let env = prim_env( + 3, + 0, + 30, + CausalContext::new(), + OperationKind::Transpose(TransposeOp { + targets, + chromatic_steps: steps, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![env]); + pitch_of(&set.reduce_onto(&base).score, pid) + }; + + // An octave up moves the alteration, not the octave. + assert_eq!(cmn_of(&run(vec![pid], 12)), (CmnNominal::C, 12, 4)); + // Saturation, silent. + assert_eq!(cmn_of(&run(vec![pid], 1000)), (CmnNominal::C, 127, 4)); + // A duplicated target shifts twice: `targets` is a multiset. + assert_eq!(cmn_of(&run(vec![pid, pid], 1)), (CmnNominal::C, 2, 4)); + } + + /// Tombstones `pitch` as replica 1's operation 0, so [`after_that_delete`] + /// names a *contiguous* prefix of replica 1 — a causal vector implies every + /// counter up to the one it names, so a lone high counter leaves the + /// dependent op pending, not applied. + fn delete_pitch_env(pitch: PitchId) -> OperationEnvelope { + prim_env( + 1, + 0, + 10, + CausalContext::new(), + OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { pitch }), + ) + } + + fn after_that_delete() -> CausalContext { + CausalContext::new().with_seen(ReplicaId(1), 0) + } + + #[test] + fn the_frozen_transpose_skips_a_tombstoned_target_and_shifts_the_live_sibling() { + // The claim the old base-free test made in its name but never checked. + let mut base = epiphany_core::generators::valid_score(0x5EED); + let ids = pitch_ids(&base); + let (dead, live) = (ids[0], ids[1]); + set_pitch(&mut base, live, cmn_pitch(CmnNominal::C, 0, 4)); + + let del = delete_pitch_env(dead); + let tr = prim_env( + 3, + 0, + 30, + after_that_delete(), + OperationKind::Transpose(TransposeOp { + targets: vec![dead, live], + chromatic_steps: 1, + }), + ); + let tr_id = tr.id; + let mut set = OperationSet::new(); + set.accept_all(vec![del, tr]); + let out = set.reduce_onto(&base); + assert_eq!( + out.state + .effects + .iter() + .find(|(e, _)| *e == tr_id) + .map(|(_, eff)| eff), + Some(&OperationEffect::Applied) + ); + // The live sibling actually moved. + assert_eq!(cmn_of(&pitch_of(&out.score, live)), (CmnNominal::C, 1, 4)); + } + + #[test] + fn transpose_interval_skips_a_tombstoned_target_and_shifts_the_live_sibling() { + let mut base = epiphany_core::generators::valid_score(0x5EED); + let ids = pitch_ids(&base); + let (dead, live) = (ids[0], ids[1]); + set_pitch(&mut base, live, cmn_pitch(CmnNominal::C, 0, 4)); + + let del = delete_pitch_env(dead); + let tr = prim_env( + 3, + 0, + 30, + after_that_delete(), + OperationKind::TransposeInterval(TransposeIntervalOp { + targets: [dead, live].into_iter().collect(), + interval: interval(4, 7), + }), + ); + let tr_id = tr.id; + let mut set = OperationSet::new(); + set.accept_all(vec![del, tr]); + let out = set.reduce_onto(&base); + assert_eq!( + out.state + .effects + .iter() + .find(|(e, _)| *e == tr_id) + .map(|(_, eff)| eff), + Some(&OperationEffect::Applied), + "a tombstoned target is SKIPPED, not a refusal" + ); + assert_eq!(cmn_of(&pitch_of(&out.score, live)), (CmnNominal::G, 0, 4)); + } + + #[test] + fn transpose_effect_log_skips_tombstoned_targets_and_refuses_a_missing_one() { // Operation Catalog §Transpose (re-anchoring): "Tombstoned targets are // skipped (the transpose applies only to live pitches)." + // + // SCOPE: this reduces base-free, so it observes the EFFECT LOG only — + // `graph` is `None` and no pitch value exists to check. It was named + // "...and_shifts_the_live_ones" and asserted no shift whatsoever; a + // gutted `graph_transpose_pitch` left it green. The shift itself is + // locked by `the_frozen_transpose_*` tests below, against `reduce_onto`. let p1 = PitchId::new(ReplicaId(9), 501); let p2 = PitchId::new(ReplicaId(9), 502); let neutral = crate::valuegen::pitch_value(); @@ -9026,6 +9591,13 @@ mod tests { // cannot detect a repeat-value leak into the base — the dedicated // `the_canonical_base_embeds_no_repeat_values` test below covers // that with an APPLIED create.) + // + // Re-pinned again at Push 4a: `gen_payload` gained `TransposeInterval`, + // discriminant 30, and `rng.below(27)` became `below(28)` — which + // reshuffles the whole seeded stream, so every operation id and effect + // moves. Nothing leaked: `canonical_bytes` embeds effects, conflicts, + // and anomalies, never payload values, and the new payload's + // constituents are major-0 layouts regardless. let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xBA5E); let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200); let mut set = OperationSet::new(); @@ -9035,7 +9607,7 @@ mod tests { let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!( hex, - "6e47a3113cfc54116af2e4a1b66fae16b9d1b63436fd26d9e6fc332bf501f5ed" + "594f29e20250a9ff36f0033a59e380e4514aa32a6e78d062579b950667439e20" ); } diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index 9c1060f..ad1e2bd 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -100,6 +100,9 @@ pub enum V0OperationKind { // Repeat authoring (schema-major-2 revision) — round-trip by identity. CreateRepeatStructure(crate::payload::CreateRepeatStructureOp), DeleteRepeatStructure(crate::payload::DeleteRepeatStructureOp), + /// Born at wire-disc 30 under major-0 layouts (Push 4a); no lossy v0 + /// form, so it projects verbatim like the repeat-authoring pair. + TransposeInterval(crate::payload::TransposeIntervalOp), } /// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant