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<PitchId> -- 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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-09 15:46:43 -04:00
parent 27c80a0d83
commit 2740a6c53c
12 changed files with 877 additions and 72 deletions

View File

@ -1561,7 +1561,7 @@ pub struct Instrument {
/// B-flat clarinet is `-1` diatonic, `-2` chromatic). Schema major 2 /// B-flat clarinet is `-1` diatonic, `-2` chromatic). Schema major 2
/// (appended; migration default `None`). /// (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 /// (`req:pitch:transposition`). What is still unimplemented is its
/// *automatic application* at the written/sounding boundary: nothing here /// *automatic application* at the written/sounding boundary: nothing here
/// respells a written part into a sounding one, or resolves either to a /// respells a written part into a sounding one, or resolves either to a

View File

@ -345,6 +345,14 @@ pub(crate) fn subjects_of(kind: &OperationKind, score: &Score) -> BarrierSubject
.map(|pitch| (TypedObjectId::Pitch(*pitch), pitch_context(score, *pitch))) .map(|pitch| (TypedObjectId::Pitch(*pitch), pitch_context(score, *pitch)))
.collect(), .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) => { OperationKind::InsertIdentifiedPitch(op) => {
// The op mutates the host event's pitch list *and* mints the pitch: // The op mutates the host event's pitch list *and* mints the pitch:
// both are named targets. // both are named targets.

View File

@ -59,8 +59,8 @@ use epiphany_core::{
PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId,
RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal, RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal,
SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration,
TimeSignature, TimeSignatureDisplay, TransactionId, TuningReference, TupletId, TypedObjectId, TimeSignature, TimeSignatureDisplay, TransactionId, TranspositionInterval, TuningReference,
VoiceId, WallClockTime, TupletId, TypedObjectId, VoiceId, WallClockTime,
}; };
use epiphany_layout_ir::{ use epiphany_layout_ir::{
active_clef_or, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, active_clef_or, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical,
@ -73,7 +73,7 @@ use epiphany_ops::{
DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp,
ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationKindTag, ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationKindTag,
OperationPayload, OperationSet, OperationStamp, RespellPitchOp, TransactionCategory, 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 /// 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) TransactionId::new(self.replica, next)
} }
/// Transposes the selected pitch by `chromatic_steps` (a `+1` is a sharpen). /// Transposes the selected pitch by `interval`. Errors if nothing — or a
/// Errors if nothing — or a non-pitch — is selected. /// 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( pub fn transpose_selection(
&mut self, &mut self,
chromatic_steps: i32, interval: TranspositionInterval,
) -> Result<EditOutcome, EditorError> { ) -> Result<EditOutcome, EditorError> {
let selection = self.selection.ok_or(EditorError::NoSelection)?; let selection = self.selection.ok_or(EditorError::NoSelection)?;
let TypedObjectId::Pitch(pitch) = selection.source else { let TypedObjectId::Pitch(pitch) = selection.source else {
return Err(EditorError::WrongSelection { expected: "pitch" }); return Err(EditorError::WrongSelection { expected: "pitch" });
}; };
self.apply(OperationKind::Transpose(TransposeOp { self.apply(OperationKind::TransposeInterval(TransposeIntervalOp {
targets: vec![pitch], targets: [pitch].into_iter().collect(),
chromatic_steps, 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<EditOutcome, EditorError> {
self.transpose_selection(TranspositionInterval {
diatonic_steps: 0,
chromatic_steps: semitones,
})
}
/// Deletes the selected object. A selected **pitch** (a notehead) is tombstoned /// 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 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. /// 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 { mod tests {
use super::*; use super::*;
use epiphany_core::generators::{valid_score, valid_score_rich}; 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::{ use epiphany_layout_ir::{
ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState, ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState,
SolverTier, SolverVersion, StubSolver, SolverTier, SolverVersion, StubSolver,
}; };
use epiphany_ops::TransposeOp;
fn open_rich(seed: u64) -> EditorSession { fn open_rich(seed: u64) -> EditorSession {
EditorSession::open(valid_score_rich(seed), Box::new(StubSolver)).expect("rich renders") 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)); assert_eq!(selection.source, TypedObjectId::Slur(slur_id));
// A slur is not an editable target: an edit op cleanly refuses it // A slur is not an editable target: an edit op cleanly refuses it
// rather than mishandling the non-pitch selection. // 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 /// 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) { for (onset, pid) in events.iter().skip(1) {
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
select_pitch(&mut session, *pid); select_pitch(&mut session, *pid);
if session.transpose_selection(1).is_err() { if session.alter_selection(1).is_err() {
continue; continue;
} }
let glyph_x = |synthesized: bool| { let glyph_x = |synthesized: bool| {
@ -4241,7 +4265,7 @@ mod tests {
// Click a notehead, then sharpen the selected pitch — minting the operation // Click a notehead, then sharpen the selected pitch — minting the operation
// is the session's job, not the caller's. // is the session's job, not the caller's.
let selection = click_a_notehead(&mut session); 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!(outcome.graph_changed, "the edit reduced onto the graph");
assert!( assert!(
@ -4260,10 +4284,7 @@ mod tests {
fn transpose_requires_a_pitch_selection() { fn transpose_requires_a_pitch_selection() {
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
// Nothing selected. // Nothing selected.
assert_eq!( assert_eq!(session.alter_selection(1), Err(EditorError::NoSelection));
session.transpose_selection(1),
Err(EditorError::NoSelection)
);
// Select a non-pitch object (a region, present in any score) and try again. // Select a non-pitch object (a region, present in any score) and try again.
let non_pitch = session let non_pitch = session
.hit_test() .hit_test()
@ -4274,7 +4295,7 @@ mod tests {
if let Some(id) = non_pitch { if let Some(id) = non_pitch {
session.select(id); session.select(id);
assert_eq!( assert_eq!(
session.transpose_selection(1), session.alter_selection(1),
Err(EditorError::WrongSelection { expected: "pitch" }) Err(EditorError::WrongSelection { expected: "pitch" })
); );
} }
@ -4285,9 +4306,9 @@ mod tests {
// Distinct minted op ids, so the second edit is not deduplicated. // Distinct minted op ids, so the second edit is not deduplicated.
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
click_a_notehead(&mut session); 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(); let mid = session.score().clone();
assert!(session.transpose_selection(1).unwrap().graph_changed); assert!(session.alter_selection(1).unwrap().graph_changed);
assert_ne!( assert_ne!(
&mid, &mid,
session.score(), session.score(),
@ -5217,8 +5238,8 @@ mod tests {
assert!(session.last_applied().is_none()); assert!(session.last_applied().is_none());
click_a_notehead(&mut session); click_a_notehead(&mut session);
session.transpose_selection(1).unwrap(); session.alter_selection(1).unwrap();
session.transpose_selection(-1).unwrap(); session.alter_selection(-1).unwrap();
// Two edits, two log entries, distinct ids, in application order, and // Two edits, two log entries, distinct ids, in application order, and
// last_applied is the tail. // last_applied is the tail.
@ -5226,13 +5247,81 @@ mod tests {
assert_eq!(log.len(), 2); assert_eq!(log.len(), 2);
assert_ne!(log[0].id, log[1].id); assert_ne!(log[0].id, log[1].id);
assert_eq!(session.last_applied(), Some(&log[1])); 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!( assert!(matches!(
log[1].payload, 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] #[test]
fn undo_and_redo_a_transpose() { fn undo_and_redo_a_transpose() {
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
@ -5241,7 +5330,7 @@ mod tests {
panic!("a notehead selects a pitch"); panic!("a notehead selects a pitch");
}; };
let before = session.current_pitch(pid).unwrap(); 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(); let after = session.current_pitch(pid).unwrap();
assert_ne!(before, after); assert_ne!(before, after);
assert!(session.can_undo() && !session.can_redo()); assert!(session.can_undo() && !session.can_redo());
@ -5411,14 +5500,14 @@ mod tests {
assert!(session.redo().is_none(), "nothing to redo"); assert!(session.redo().is_none(), "nothing to redo");
click_a_notehead(&mut session); click_a_notehead(&mut session);
session.transpose_selection(1).expect("e1"); session.alter_selection(1).expect("e1");
session.transpose_selection(1).expect("e2"); session.alter_selection(1).expect("e2");
assert_eq!(session.applied_operations().len(), 2); assert_eq!(session.applied_operations().len(), 2);
session.undo().expect("undo e2"); session.undo().expect("undo e2");
assert!(session.can_redo()); assert!(session.can_redo());
// A new edit past an undo clears the redo stack. // 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!(!session.can_redo(), "the new edit forked away the redo");
assert_eq!( assert_eq!(
session.applied_operations().len(), session.applied_operations().len(),
@ -5431,10 +5520,10 @@ mod tests {
fn a_fork_mints_a_fresh_operation_id_not_the_undone_one() { fn a_fork_mints_a_fresh_operation_id_not_the_undone_one() {
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
click_a_notehead(&mut session); 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; let a_id = session.last_applied().unwrap().id;
session.undo().expect("undo A"); 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; 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"); 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() { fn edits_after_a_fork_re_reduce_cleanly() {
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
click_a_notehead(&mut session); click_a_notehead(&mut session);
session.transpose_selection(1).expect("A"); session.alter_selection(1).expect("A");
session.transpose_selection(1).expect("B"); session.alter_selection(1).expect("B");
session.undo().expect("undo B"); session.undo().expect("undo B");
// Each edit re-reduces the whole active prefix; if a forked op were stranded // 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 // 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 // reduction would be unclean and the edit would be refused. So these succeeding
// is the proof the fork's causal contexts are correct. // is the proof the fork's causal contexts are correct.
session.transpose_selection(-1).expect("C"); session.alter_selection(-1).expect("C");
session.transpose_selection(-1).expect("D"); session.alter_selection(-1).expect("D");
let active = session.applied_operations(); let active = session.applied_operations();
assert_eq!(active.len(), 3, "A, C, D active; B forked away"); 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() { fn with_identity_is_refused_after_an_undone_edit() {
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
click_a_notehead(&mut session); click_a_notehead(&mut session);
session.transpose_selection(1).expect("edit"); session.alter_selection(1).expect("edit");
session.undo().expect("undo"); session.undo().expect("undo");
assert!( assert!(
session.applied_operations().is_empty(), session.applied_operations().is_empty(),
@ -5542,8 +5631,8 @@ mod tests {
// conflicts — both here and on a peer replaying the log. // conflicts — both here and on a peer replaying the log.
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
click_a_notehead(&mut session); click_a_notehead(&mut session);
session.transpose_selection(1).unwrap(); session.alter_selection(1).unwrap();
session.transpose_selection(1).unwrap(); session.alter_selection(1).unwrap();
let log = session.applied_operations(); let log = session.applied_operations();
assert_eq!(log.len(), 2); assert_eq!(log.len(), 2);
@ -5575,7 +5664,7 @@ mod tests {
// `(new_replica, 0)` hole. The session refuses it. // `(new_replica, 0)` hole. The session refuses it.
let mut session = open_rich(0x5EED); let mut session = open_rich(0x5EED);
click_a_notehead(&mut session); click_a_notehead(&mut session);
session.transpose_selection(1).unwrap(); session.alter_selection(1).unwrap();
let _ = session.with_identity(ReplicaId(2), AuthorId(0)); 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)); let mut session = open_rich(0x5EED).with_identity(ReplicaId::SYSTEM_DERIVED, AuthorId(0));
click_a_notehead(&mut session); click_a_notehead(&mut session);
assert_eq!( assert_eq!(
session.transpose_selection(1), session.alter_selection(1),
Err(EditorError::RejectedOperation) Err(EditorError::RejectedOperation)
); );
assert!( assert!(
@ -5608,7 +5697,7 @@ mod tests {
// not even the operation counter — mutates, so a later valid edit still // not even the operation counter — mutates, so a later valid edit still
// works. // works.
assert_eq!( assert_eq!(
session.transpose_selection(1), session.alter_selection(1),
Err(EditorError::RejectedOperation) Err(EditorError::RejectedOperation)
); );
assert_eq!( assert_eq!(

View File

@ -128,6 +128,7 @@ fn payload_label(payload: &OperationPayload) -> &'static str {
match payload { match payload {
OperationPayload::Primitive(kind) => match kind { OperationPayload::Primitive(kind) => match kind {
OperationKind::Transpose(_) => "Transpose", OperationKind::Transpose(_) => "Transpose",
OperationKind::TransposeInterval(_) => "TransposeInterval",
OperationKind::ModifyIdentifiedPitch(_) => "ModifyIdentifiedPitch", OperationKind::ModifyIdentifiedPitch(_) => "ModifyIdentifiedPitch",
OperationKind::DeleteIdentifiedPitch(_) => "DeleteIdentifiedPitch", OperationKind::DeleteIdentifiedPitch(_) => "DeleteIdentifiedPitch",
OperationKind::DeleteEvent(_) => "DeleteEvent", OperationKind::DeleteEvent(_) => "DeleteEvent",
@ -281,10 +282,10 @@ impl EditorApp {
self.run("delete", |s| s.delete_selection()); self.run("delete", |s| s.delete_selection());
} }
if ui.button("Transpose ♯").clicked() { 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() { 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() { if ui.button("Move ↑").clicked() {
self.run("move up", |s| s.move_selection_staff_step(1)); 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)); self.run("move down", |s| s.move_selection_staff_step(-1));
} }
if k.sharp { if k.sharp {
self.run("transpose +1", |s| s.transpose_selection(1)); self.run("transpose +1", |s| s.alter_selection(1));
} }
if k.flat { if k.flat {
self.run("transpose -1", |s| s.transpose_selection(-1)); self.run("transpose -1", |s| s.alter_selection(-1));
} }
if k.add { if k.add {
self.run("add chord note", |s| s.add_note_to_selection()); self.run("add chord note", |s| s.add_note_to_selection());

View File

@ -1276,5 +1276,47 @@ gutting `graph_transpose_pitch` entirely leaves
`transpose_skips_system_derived_targets_p12_k3` both green. They call base-free `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 `reduce()`, where `graph` is `None` and the function never runs, and they
assert only `OperationEffect`. Only `editor-core`'s `undo_and_redo_a_transpose` assert only `OperationEffect`. Only `editor-core`'s `undo_and_redo_a_transpose`
— three crates away — catches it. Both are rewritten against `reduce_onto()` — three crates away — catches it.
with asserted pitch values as part of the implementation.
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.

View File

@ -143,8 +143,13 @@ pub enum PreconditionFailureReason {
PositionOutsideRegion, PositionOutsideRegion,
/// A pitch-space or tuning-context precondition failed. /// A pitch-space or tuning-context precondition failed.
/// ///
/// Reserved: producing this requires the Chapter 4 tuning catalog /// Produced by `TransposeInterval` against a target whose
/// (pitch-space registry), which is deferred Track-C work. /// `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, PitchSpaceMismatch,
/// The operation targeted a voice that does not exist or is tombstoned. /// The operation targeted a voice that does not exist or is tombstoned.
VoiceMissing, VoiceMissing,
@ -171,6 +176,15 @@ pub enum PreconditionFailureReason {
/// content disagrees (Pass 12, P12-K9 — replaces the `TargetMissing` /// content disagrees (Pass 12, P12-K9 — replaces the `TargetMissing`
/// misnomer at the value-retaining re-create sites). /// misnomer at the value-retaining re-create sites).
RecreateContentMismatch, 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 { impl PreconditionFailureReason {
@ -193,6 +207,9 @@ impl PreconditionFailureReason {
// Additive (Pass-12 G-pass, P12-K3/P12-K9); appended past 11. // Additive (Pass-12 G-pass, P12-K3/P12-K9); appended past 11.
PreconditionFailureReason::SystemDerivedContentImmutable => 12, PreconditionFailureReason::SystemDerivedContentImmutable => 12,
PreconditionFailureReason::RecreateContentMismatch => 13, PreconditionFailureReason::RecreateContentMismatch => 13,
// Additive (Push 4a, TransposeInterval); appended past 13.
PreconditionFailureReason::AcousticRealizationPinned => 14,
PreconditionFailureReason::TranspositionOutOfRange => 15,
} }
} }
} }

View File

@ -22,7 +22,8 @@
use epiphany_core::{ use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId, 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; use epiphany_determinism::fuzz::SplitMix64;
@ -36,7 +37,7 @@ use crate::payload::{
DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp,
ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp,
SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp,
SetUserSystemBreakOp, TransposeOp, TupletCompensation, SetUserSystemBreakOp, TransposeIntervalOp, TransposeOp, TupletCompensation,
}; };
use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId; use crate::support::AuthorId;
@ -86,7 +87,7 @@ fn pitch(n: u64) -> PitchId {
/// Generates a random payload over the shared id space. /// Generates a random payload over the shared id space.
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
let kind = match rng.below(27) { let kind = match rng.below(28) {
0 => { 0 => {
let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let voice = VoiceId::new(ReplicaId(7), rng.below(3));
let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); 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 { 26 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp {
repeat: RepeatStructureId::new(ReplicaId(7), rng.below(3)), 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 { _ => OperationKind::SetStaffLayout(SetStaffLayoutOp {
staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)),
instrument_override: None, instrument_override: None,

View File

@ -124,7 +124,7 @@ pub use payload::{
OperationPayload, PositionRemapping, ResolveConflictPayload, ResolveEquivocationPayload, OperationPayload, PositionRemapping, ResolveConflictPayload, ResolveEquivocationPayload,
RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp,
SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory,
TransactionDescriptor, TransposeOp, TupletCompensation, TransactionDescriptor, TransposeIntervalOp, TransposeOp, TupletCompensation,
}; };
pub use reduce::{ pub use reduce::{
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,

View File

@ -177,6 +177,8 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind {
V0OperationKind::CreateRepeatStructure(op.clone()) V0OperationKind::CreateRepeatStructure(op.clone())
} }
OperationKind::DeleteRepeatStructure(op) => V0OperationKind::DeleteRepeatStructure(*op), 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
V0OperationKind::CreateRepeatStructure(op) => { V0OperationKind::CreateRepeatStructure(op) => {
OperationKind::CreateRepeatStructure(op.clone()) OperationKind::CreateRepeatStructure(op.clone())
} }
V0OperationKind::TransposeInterval(op) => OperationKind::TransposeInterval(op.clone()),
V0OperationKind::DeleteRepeatStructure(op) => OperationKind::DeleteRepeatStructure(*op), V0OperationKind::DeleteRepeatStructure(op) => OperationKind::DeleteRepeatStructure(*op),
}) })
} }

View File

@ -36,10 +36,12 @@ use epiphany_core::{
InstrumentId, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, InstrumentId, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
PitchSpelling, Region, RegionId, RegionTimeModel, RepeatStructure, RepeatStructureId, Rest, PitchSpelling, Region, RegionId, RegionTimeModel, RepeatStructure, RepeatStructureId, Rest,
ScoreMetadata, Slur, Spanner, Staff, StaffId, StaffInstance, StaffInstanceId, ScoreMetadata, Slur, Spanner, Staff, StaffId, StaffInstance, StaffInstanceId,
StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, TimeSignature, TransactionId, TupletId, StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, TimeSignature, TransactionId,
TypedObjectId, Voice, VoiceId, 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::conflict::{ConflictId, ResolutionAction};
use crate::encode::{push_canon, push_lp_bytes, push_seq, push_str, push_tag, push_u8_bool}; 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), CreateRepeatStructure(CreateRepeatStructureOp),
/// Tombstone a repeat structure (delete-wins, idempotent on re-delete). /// Tombstone a repeat structure (delete-wins, idempotent on re-delete).
DeleteRepeatStructure(DeleteRepeatStructureOp), DeleteRepeatStructure(DeleteRepeatStructureOp),
/// Push 4a: the faithful transpose. Appended past 29 — a schema-minor
/// vocabulary append (`req:binfmt:kind-discriminants`).
TransposeInterval(TransposeIntervalOp),
} }
impl OperationKind { impl OperationKind {
@ -279,6 +284,9 @@ impl OperationKind {
// Schema-major-2 revision; appended past the Phase-3 24..=27. // Schema-major-2 revision; appended past the Phase-3 24..=27.
OperationKind::CreateRepeatStructure(_) => 28, OperationKind::CreateRepeatStructure(_) => 28,
OperationKind::DeleteRepeatStructure(_) => 29, 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. // Name-verbatim projection, as the cross-cutting tags do.
OperationKind::CreateRepeatStructure(_) => OperationKindTag::CreateRepeatStructure, OperationKind::CreateRepeatStructure(_) => OperationKindTag::CreateRepeatStructure,
OperationKind::DeleteRepeatStructure(_) => OperationKindTag::DeleteRepeatStructure, OperationKind::DeleteRepeatStructure(_) => OperationKindTag::DeleteRepeatStructure,
OperationKind::TransposeInterval(_) => OperationKindTag::TransposeInterval,
} }
} }
} }
@ -340,6 +349,7 @@ impl CanonicalEncode for OperationKind {
} }
OperationKind::ModifyEvent(op) => op.encode_canonical(out), OperationKind::ModifyEvent(op) => op.encode_canonical(out),
OperationKind::Transpose(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::InsertIdentifiedPitch(op) => op.encode_canonical(out),
OperationKind::DeleteIdentifiedPitch(op) => op.encode_canonical(out), OperationKind::DeleteIdentifiedPitch(op) => op.encode_canonical(out),
OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out), OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out),
@ -404,6 +414,8 @@ pub enum OperationKindTag {
// cross-cutting tags are. // cross-cutting tags are.
CreateRepeatStructure, CreateRepeatStructure,
DeleteRepeatStructure, DeleteRepeatStructure,
/// Push 4a.
TransposeInterval,
} }
impl OperationKindTag { impl OperationKindTag {
@ -441,6 +453,8 @@ impl OperationKindTag {
// Schema-major-2 revision; appended past the Phase-3 24..=27. // Schema-major-2 revision; appended past the Phase-3 24..=27.
OperationKindTag::CreateRepeatStructure => 28, OperationKindTag::CreateRepeatStructure => 28,
OperationKindTag::DeleteRepeatStructure => 29, 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). /// **Frozen; replay only** (operation_catalog §Transpose,
/// Pitch identifiers are preserved; reduction is order-dependent in the general /// `req:opcat:transpose-frozen`). New authoring emits [`TransposeIntervalOp`].
/// case (interval composition need not commute). `chromatic_steps` is a minimal ///
/// interval (a CMN alteration shift) that, in this prototype, commutes except at /// Shifts each live target's CMN `alteration` by `chromatic_steps`. Its
/// the alteration's `i8` saturation bound; rich interval algebra is deferred /// semantics are pinned exactly as they shipped, because an operation is
/// (Chapter 4 tuning catalog; P12-K2). /// 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)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct TransposeOp { pub struct TransposeOp {
pub targets: Vec<PitchId>, pub targets: Vec<PitchId>,
@ -1012,11 +1038,42 @@ pub struct TransposeOp {
impl CanonicalEncode for TransposeOp { impl CanonicalEncode for TransposeOp {
fn encode_canonical(&self, out: &mut Vec<u8>) { fn encode_canonical(&self, out: &mut Vec<u8>) {
// `sorted_canonical`, NOT a set: the multiset is frozen wire form.
push_seq(out, &sorted_canonical(self.targets.clone())); push_seq(out, &sorted_canonical(self.targets.clone()));
out.extend_from_slice(&self.chromatic_steps.to_le_bytes()); 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<PitchId>,
pub interval: TranspositionInterval,
}
impl CanonicalEncode for TransposeIntervalOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
// 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<PitchId> = 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 /// Add a pitch to a live event (Chapter 6 §6.10 InsertIdentifiedPitch). Carries
/// the full [`IdentifiedPitch`] (v1). Mint discipline. /// the full [`IdentifiedPitch`] (v1). Mint discipline.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]

View File

@ -32,14 +32,14 @@ use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use epiphany_core::{ use epiphany_core::{
canonical_pitch_bytes, derive_promoted_voice_id, AnchorOffset, AnnotationAnchor, canonical_pitch_bytes, derive_promoted_voice_id, simplest_spelling, AnchorOffset,
CanonicalValue, Event, EventDuration, EventId, EventPosition, GestureAnchoring, InstrumentId, AnnotationAnchor, CanonicalValue, Event, EventDuration, EventId, EventPosition,
MeterChange, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, GestureAnchoring, InstrumentId, MeterChange, MetricGrid, MusicalDuration, MusicalPosition,
PitchSpelling, RationalTime, RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, OperationId, Pitch, PitchId, PitchSpelling, RationalTime, RegionEdge, RegionId,
ScoreMetadata, SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource, Staff, RegionTimeModel, ReplicaId, Score, ScoreMetadata, SpellingAttachment, SpellingDirective,
StaffId, StaffInstance, StaffInstanceId, StaffLineConfiguration, TempoMap, TempoSegment, SpellingScope, SpellingSource, Staff, StaffId, StaffInstance, StaffInstanceId,
TempoShape, TimeAnchor, TimeSignature, TimeSignatureId, TransactionId, TypedObjectId, Voice, StaffLineConfiguration, TempoMap, TempoSegment, TempoShape, TimeAnchor, TimeSignature,
VoiceId, VoiceOrigin, TimeSignatureId, TransactionId, TransposeRefusal, TypedObjectId, Voice, VoiceId, VoiceOrigin,
}; };
use epiphany_determinism::CanonicalEncode; use epiphany_determinism::CanonicalEncode;
@ -61,7 +61,7 @@ use crate::payload::{
DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp,
ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload,
RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp,
SetTimeSignatureOp, SetUserPageBreakOp, TransposeOp, TupletCompensation, SetTimeSignatureOp, SetUserPageBreakOp, TransposeIntervalOp, TransposeOp, TupletCompensation,
}; };
use crate::stamp::StampTuple; use crate::stamp::StampTuple;
use crate::support::{ObjectKind, SerializedCanonicalInputs}; use crate::support::{ObjectKind, SerializedCanonicalInputs};
@ -2502,6 +2502,7 @@ impl<'a> Reducer<'a> {
OperationKind::Registered(_, _) => OperationEffect::Applied, OperationKind::Registered(_, _) => OperationEffect::Applied,
OperationKind::ModifyEvent(op) => self.modify_event(env, op), OperationKind::ModifyEvent(op) => self.modify_event(env, op),
OperationKind::Transpose(op) => self.transpose(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::InsertIdentifiedPitch(op) => self.insert_identified_pitch(env, op),
OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op), OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op),
OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op), OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op),
@ -5540,6 +5541,164 @@ impl<'a> Reducer<'a> {
OperationEffect::Applied 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<PitchId> = 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<PitchId> = 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<Pitch> {
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( fn insert_identified_pitch(
&mut self, &mut self,
env: &OperationEnvelope, env: &OperationEnvelope,
@ -8774,10 +8933,416 @@ mod tests {
env 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<PitchId> {
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] #[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<PitchId>, 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 // Operation Catalog §Transpose (re-anchoring): "Tombstoned targets are
// skipped (the transpose applies only to live pitches)." // 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 p1 = PitchId::new(ReplicaId(9), 501);
let p2 = PitchId::new(ReplicaId(9), 502); let p2 = PitchId::new(ReplicaId(9), 502);
let neutral = crate::valuegen::pitch_value(); let neutral = crate::valuegen::pitch_value();
@ -9026,6 +9591,13 @@ mod tests {
// cannot detect a repeat-value leak into the base — the dedicated // cannot detect a repeat-value leak into the base — the dedicated
// `the_canonical_base_embeds_no_repeat_values` test below covers // `the_canonical_base_embeds_no_repeat_values` test below covers
// that with an APPLIED create.) // 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 mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xBA5E);
let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200); let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200);
let mut set = OperationSet::new(); let mut set = OperationSet::new();
@ -9035,7 +9607,7 @@ mod tests {
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!( assert_eq!(
hex, hex,
"6e47a3113cfc54116af2e4a1b66fae16b9d1b63436fd26d9e6fc332bf501f5ed" "594f29e20250a9ff36f0033a59e380e4514aa32a6e78d062579b950667439e20"
); );
} }

View File

@ -100,6 +100,9 @@ pub enum V0OperationKind {
// Repeat authoring (schema-major-2 revision) — round-trip by identity. // Repeat authoring (schema-major-2 revision) — round-trip by identity.
CreateRepeatStructure(crate::payload::CreateRepeatStructureOp), CreateRepeatStructure(crate::payload::CreateRepeatStructureOp),
DeleteRepeatStructure(crate::payload::DeleteRepeatStructureOp), 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 /// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant