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:
parent
27c80a0d83
commit
2740a6c53c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<EditOutcome, EditorError> {
|
||||
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<EditOutcome, EditorError> {
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
V0OperationKind::CreateRepeatStructure(op) => {
|
||||
OperationKind::CreateRepeatStructure(op.clone())
|
||||
}
|
||||
V0OperationKind::TransposeInterval(op) => OperationKind::TransposeInterval(op.clone()),
|
||||
V0OperationKind::DeleteRepeatStructure(op) => OperationKind::DeleteRepeatStructure(*op),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PitchId>,
|
||||
|
|
@ -1012,11 +1038,42 @@ pub struct TransposeOp {
|
|||
|
||||
impl CanonicalEncode for TransposeOp {
|
||||
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()));
|
||||
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
|
||||
/// the full [`IdentifiedPitch`] (v1). Mint discipline.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
&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<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]
|
||||
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
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue