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
|
/// 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue