From 91a99b2093d6d523a02ecab1d95cb514540152ed Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 9 Jul 2026 15:50:22 -0400 Subject: [PATCH] Stop the conformance suite from authoring the frozen operation req:opcat:transpose-frozen says a conforming implementation MUST NOT emit Transpose from new authoring. testkit's editloop::sharpen -- the [7c] UI-seam gate's editing action -- was emitting exactly that, so the conformance suite itself would have violated the requirement the same push introduced. It now authors TransposeInterval with (0, +1): the same alteration shift, the same staff line, the same rendered result. The random-kind corpora in generators.rs keep producing the frozen Transpose, and should: it must reduce correctly forever, and a generator is now the only thing that will ever produce one. The distinction is authoring versus replay. This is not inert. TransposeInterval also writes the Propagated spelling attachment the frozen operation never did, so the edited score genuinely changes shape. the_sharpen_records_the_spelling_it_propagated pins that, and was mutation-verified by removing graph_propagate_spelling. Gate: fmt clean, clippy 0, 30 targets / 983 passed / 0 failed, docs 0 under -D warnings, conformance 8/8, zero golden churn. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-testkit/src/editloop.rs | 65 +++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/epiphany-testkit/src/editloop.rs b/crates/epiphany-testkit/src/editloop.rs index 06dd85b..c690739 100644 --- a/crates/epiphany-testkit/src/editloop.rs +++ b/crates/epiphany-testkit/src/editloop.rs @@ -14,14 +14,16 @@ //! pitch's value but not its `PitchId`, so its layout object keeps the same stable //! id and the editor re-finds the selection after the relayout. -use epiphany_core::{OperationId, PitchId, ReplicaId, Score, TypedObjectId, WallClockTime}; +use epiphany_core::{ + OperationId, PitchId, ReplicaId, Score, TranspositionInterval, TypedObjectId, WallClockTime, +}; use epiphany_layout_ir::{ to_constrained, to_logical, to_render, ConstraintSolver, HitShape, LayoutObjectId, Point, PrimitiveRef, RenderIR, SolverConfig, StubSolver, }; use epiphany_ops::{ AuthorId, CausalContext, HybridLogicalClock, OperationEnvelope, OperationKind, - OperationPayload, OperationSet, OperationStamp, TransposeOp, + OperationPayload, OperationSet, OperationStamp, TransposeIntervalOp, }; /// What one editing-loop iteration did, for inspection by tests. @@ -57,9 +59,15 @@ fn render_with(score: &Score, solver: &S) -> Option Score { let id = OperationId::new(ReplicaId(1), 1); let envelope = OperationEnvelope { @@ -68,10 +76,15 @@ fn sharpen(base: &Score, pitch: PitchId) -> Score { stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id), causal_context: CausalContext::new(), transaction: None, - payload: OperationPayload::Primitive(OperationKind::Transpose(TransposeOp { - targets: vec![pitch], - chromatic_steps: 1, - })), + payload: OperationPayload::Primitive(OperationKind::TransposeInterval( + TransposeIntervalOp { + targets: [pitch].into_iter().collect(), + interval: TranspositionInterval { + diatonic_steps: 0, + chromatic_steps: 1, + }, + }, + )), }; let mut set = OperationSet::new(); set.accept(envelope); @@ -158,6 +171,40 @@ mod tests { use super::*; use epiphany_core::generators::valid_score_rich; + #[test] + fn the_sharpen_records_the_spelling_it_propagated() { + // The frozen `Transpose` recorded none, so an authored spelling stayed + // pinned to the pre-edit notehead. `TransposeInterval` must attach the + // spelling its interval determined (`req:opcat:transpose-interval-spelling`). + use epiphany_core::{SpellingScope, SpellingSource}; + let base = valid_score_rich(0x5EED); + let pitch = base + .events + .iter() + .find_map(|e| { + let mut ips = Vec::new(); + e.collect_identified_pitches(&mut ips); + ips.first().map(|ip| ip.id) + }) + .expect("the rich fixture has a pitch"); + assert!( + !base + .spelling_attachments + .iter() + .any(|a| matches!(&a.source, SpellingSource::Propagated { .. })), + "the fixture starts with no propagated attachment" + ); + + let edited = sharpen(&base, pitch); + assert!( + edited.spelling_attachments.iter().any(|a| { + matches!(a.source, SpellingSource::Propagated { from } if from == pitch) + && matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch) + }), + "the sharpen propagated its spelling" + ); + } + #[test] fn one_editing_loop_iteration_holds_every_seam() { // A returned report means the click resolved to a pitch-backed notehead (the