Schema major 2 Phase B: snapshot side + honest stamps (data-model fills)
The nine type bodies fill to the ratified Ch5 shapes and the wire moves
to the Binary Format §Schema Major 2 layouts, review-hardened (high-
effort review; 8 findings, 7 fixed + 1 deferral sharpened).
epiphany-core:
- 19 new leaf types in graph.rs (SlurKind/CurveDirection/
CurvatureOverride/SpanStyle/LineStyle/SpaceUnit/SubBeam/
BeamGeometryOverride/SpannerKind+5 payload types/RepeatKind/Volta/
StaffBracketKind/MetadataEntry/MetadataValue/Timestamp/
SoundConfiguration/TranspositionInterval/UnpitchedMember) with the
ratified discriminants (cstyle_enum_codec! reused; tagged unions
hand-written); nine struct fills appended in wire order.
- The frozen wire forms generalized to a SHARED sub-codec layer
(enc_/dec_*_v1, v0==v1 for every type major 2 changed; vec framing
through enc_/dec_vec_v1) used by the new decode_v1_score/
encode_v1_score AND the rerouted v0 pair — major 2 touched types the
v0 walk had treated as unchanged (metadata, staves, cross_cutting,
staff instances transitively). Strict-canonical guards on every
versioned path; decode_canonical_versioned dispatches {0,1,2} with
composed default-fill migration.
- Invariants extended to the new reference-bearing fields (REVIEW
FIX): Beam.sub_beams events, RepeatKind DaCapo/DalSegno anchors,
Volta spans now covered by CrossCuttingRefsResolve + the anchor
model walk, with negative tests.
- Tests: v1 migration size-anchor (v1 omits exactly the appended
default bytes — the frozen encoder cannot drift), a non-default
round-trip covering every new field and every SpannerKind/RepeatKind
wire arm, fuzzer corpus gains genuine-v1 forms + the major-2 seam
with enforced must-decode-Ok on unmutated frozen forms.
epiphany-ops + epiphany-bundle (Phase C's semantic core, landed here
deliberately — the live codec flip makes CrossCutting/Staff/Metadata
payload bytes v2 immediately; shimming nine transitively-embedded
types the major-1 D1 way was throwaway):
- Minimal-stamping OperationKind::schema_major per the ratified rule
(CrossCutting/CreateStaff/SetMetadata always 2; CreateRegion 2 iff a
carried instance bears Some(staff_lines_override) else 1;
CreateStaffInstance/SetStaffLayout 2 iff Some else 0), unit-locked.
- Bundle op-block accept-set [0,2]; SchemaVersion::V2;
beyond-accept-set tests moved to major 3; testkit V2
stamp-derivation test.
- the_canonical_base_is_byte_identical_across_data_model_majors:
pinned blake3 of a seeded reduction — the companion's SHOULD that
the canonical base never moves across data-model majors.
- The op-payload migrate-on-read deferral restated precisely in
DECISIONS (no consumer byte-reconstructs op payloads today; the
first one must bring per-type frozen payload decoders).
Zero golden churn (fixtures deliberately carry v2 defaults).
Instrument::new consolidates the sweep's default fills. Full gate:
fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace suites,
conformance scale 1 (8/8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
parent
7c9d1c40aa
commit
794400c4c7
|
|
@ -401,3 +401,13 @@ do not include the field. This crate places `retention_policy` inside
|
|||
`ProfileConstraints`. The spec should show the field explicitly (and confirm
|
||||
whether a bundle declaring multiple profiles resolves retention from the first
|
||||
declared profile, as this crate does).
|
||||
|
||||
## Schema major 2: op-block accept-set raised to [0, 2]
|
||||
|
||||
`max_supported_major(OperationEnvelopeBlock)` → 2 (same commit as the core
|
||||
fills + ops stamps, so stamps never lag bytes); every other role stays at
|
||||
major 0 — including the payload-polymorphic Snapshot: nothing stages an
|
||||
acceleration full-`Score` snapshot yet, so its role gate waits for a real
|
||||
producer (the core-side seam `decode_canonical_versioned` already handles
|
||||
{0,1,2}). `SchemaVersion::V2` added; beyond-accept-set tests moved to
|
||||
major 3.
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@ pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0;
|
|||
/// bound of its per-role accept-set `[0, max]` (Binary Format companion
|
||||
/// §"Schema Major 1", "The accept-set gate").
|
||||
///
|
||||
/// `OperationEnvelopeBlock` admits major 1 (its v1 form embeds a v1
|
||||
/// `CreateRegion`; the reader treats the block bytes opaquely, so it parses a
|
||||
/// v1 block without decoding the payload). Every other role stays at
|
||||
/// `OperationEnvelopeBlock` admits major 2 (schema major 2 fills the
|
||||
/// cross-cutting/staff/metadata bodies its payloads embed; major 1 embedded a
|
||||
/// v1 `CreateRegion`; the reader treats the block bytes opaquely, so it
|
||||
/// parses a higher-major block without decoding the payload). Every other
|
||||
/// role stays at
|
||||
/// [`SUPPORTED_SCHEMA_MAJOR`] until its own versioned path lands — including the
|
||||
/// payload-polymorphic `Snapshot` (the acceleration form's migrate-on-read is
|
||||
/// core-side; the canonical base stays major 0) and the manifest (carried
|
||||
|
|
@ -61,7 +63,7 @@ pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0;
|
|||
/// (a major-0-only reader meeting a v1 op block), not a hard reject.
|
||||
pub fn max_supported_major(kind: ChunkKind) -> u16 {
|
||||
match kind {
|
||||
ChunkKind::OperationEnvelopeBlock => 1,
|
||||
ChunkKind::OperationEnvelopeBlock => 2,
|
||||
_ => SUPPORTED_SCHEMA_MAJOR,
|
||||
}
|
||||
}
|
||||
|
|
@ -1264,11 +1266,14 @@ mod tests {
|
|||
// Admission of major 1 is raised **per chunk role**, never as a blanket
|
||||
// accept-set (Binary Format companion §"Schema Major 1"). D2 raised the
|
||||
// operation-envelope-block role to major 1 (a block bearing a v1
|
||||
// CreateRegion); every other role stays exact-0 until its own versioned
|
||||
// path lands, and the manifest stays major 0 forever.
|
||||
// CreateRegion), and schema major 2 to major 2 (a block bearing a v2
|
||||
// cross-cutting/staff/metadata value); every other role stays exact-0
|
||||
// until its own versioned path lands, and the manifest stays major 0
|
||||
// forever.
|
||||
assert_eq!(SchemaVersion::V1.major, 1);
|
||||
// The op-block role admits [0, 1].
|
||||
assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 1);
|
||||
assert_eq!(SchemaVersion::V2.major, 2);
|
||||
// The op-block role admits [0, 2].
|
||||
assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 2);
|
||||
// Every other role stays at the generic baseline (major 0): the
|
||||
// payload-polymorphic Snapshot (its migrate-on-read is core-side), the
|
||||
// layout cache, and the operation index.
|
||||
|
|
@ -1290,7 +1295,7 @@ mod tests {
|
|||
let mut bundle = fresh_bundle();
|
||||
let block = StagedChunk::operation_block_versioned(
|
||||
crate::block::encode_block(&[vec![1u8, 2, 3]]),
|
||||
SchemaVersion::new(2, 0),
|
||||
SchemaVersion::new(3, 0),
|
||||
);
|
||||
bundle
|
||||
.commit(&[block], |ctx| {
|
||||
|
|
@ -1306,7 +1311,7 @@ mod tests {
|
|||
);
|
||||
assert!(bundle.anomalies().iter().any(|a| matches!(
|
||||
a,
|
||||
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 2 }
|
||||
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 3 }
|
||||
)));
|
||||
// A further commit against the now-read-only bundle is refused.
|
||||
let more = StagedChunk::operation_block_versioned(
|
||||
|
|
|
|||
|
|
@ -180,6 +180,14 @@ impl SchemaVersion {
|
|||
/// and operation blocks without a changed payload stay at [`Self::V0`].
|
||||
pub const V1: SchemaVersion = SchemaVersion { major: 1, minor: 0 };
|
||||
|
||||
/// Schema major 2 — the second data-model expansion major (Binary Format
|
||||
/// companion §"Schema Major 2"): the cross-cutting bodies, repeats/voltas,
|
||||
/// staff/instrument/metadata fills. Stamped (minimally — the lowest major
|
||||
/// whose layouts decode the bytes) on chunks whose payload carries a v2
|
||||
/// layout: the acceleration full-`Score` snapshot and any
|
||||
/// operation-envelope block bearing a v2 value.
|
||||
pub const V2: SchemaVersion = SchemaVersion { major: 2, minor: 0 };
|
||||
|
||||
/// Constructs a schema version.
|
||||
#[inline]
|
||||
pub const fn new(major: u16, minor: u16) -> Self {
|
||||
|
|
@ -187,15 +195,16 @@ impl SchemaVersion {
|
|||
}
|
||||
|
||||
/// The current schema version at a given major: [`Self::V0`] for major 0,
|
||||
/// [`Self::V1`] for major 1, and `{major, 0}` for any higher (future) major.
|
||||
/// A writer maps a chunk's derived schema major to a version this way — e.g.
|
||||
/// an operation-envelope block stamps the max over its operations'
|
||||
/// `schema_major()`.
|
||||
/// [`Self::V1`] for major 1, [`Self::V2`] for major 2, and `{major, 0}`
|
||||
/// for any higher (future) major. A writer maps a chunk's derived schema
|
||||
/// major to a version this way — e.g. an operation-envelope block stamps
|
||||
/// the max over its operations' `schema_major()`.
|
||||
#[inline]
|
||||
pub const fn for_major(major: u16) -> Self {
|
||||
match major {
|
||||
0 => SchemaVersion::V0,
|
||||
1 => SchemaVersion::V1,
|
||||
2 => SchemaVersion::V2,
|
||||
m => SchemaVersion { major: m, minor: 0 },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,3 +457,36 @@ rejected up front); every production caller uses the default profile and
|
|||
`.expect`s; the stale test is rewritten as `unknown_algorithm_ids_error`.
|
||||
CONFORMANCE.md's long-standing "errors" claim is now true rather than
|
||||
aspirational.
|
||||
|
||||
## Schema major 2, Phase B: the snapshot side (data-model fills + frozen v1)
|
||||
|
||||
The nine type bodies fill to the ratified Ch5 shapes (Binary Format §Schema
|
||||
Major 2): Slur/Tie/Beam/Spanner (kind/curvature/sub-beams/geometry/style —
|
||||
one shared `SpanStyle`), RepeatStructure (kind/voltas), Staff (default_clef),
|
||||
StaffLineConfiguration (spacing/style/bracket), Instrument (six fields),
|
||||
ScoreMetadata (six fields incl. the strictly-authored timestamps). All new
|
||||
leaf types live in `graph.rs` with the ratified wire discriminants in
|
||||
`codec.rs` (`tag_only_codec!` for the tag-only enums).
|
||||
|
||||
**The frozen-form architecture generalized:** major 2's fills reach types the
|
||||
v0 walk had treated as "unchanged" (`metadata`, `staves`, `cross_cutting`,
|
||||
and — transitively through `Region.content` — the staff instances), so the
|
||||
frozen wire forms are now a *shared sub-codec layer*: `enc_/dec_*_v1`
|
||||
functions (v0 == v1 for every type major 2 changed) used by BOTH
|
||||
`decode_v1_score`/`encode_v1_score` (new) and the v0 pair (updated to route
|
||||
through them). Each versioned decoder stays strictly canonical over its own
|
||||
wire form (re-encode-and-compare, the fuzzer-P1 discipline) and the fuzzer
|
||||
corpus gained genuine-v1 forms + the major-2 seam. The
|
||||
`v1_score_migrates_default_filling_the_major_2_fields` size anchor pins that
|
||||
v1 omits exactly the appended default bytes (so the frozen encoder cannot
|
||||
silently drift), and
|
||||
`current_major_round_trips_non_default_values_for_every_major_2_field`
|
||||
exercises every new field (and every payload-carrying SpannerKind variant)
|
||||
as real wire content.
|
||||
|
||||
**Deliberate scope choices:** generator fixtures were NOT given non-default
|
||||
v2 content — that would churn the render goldens and reference-suite metrics
|
||||
for zero coverage the codec tests don't already provide; ops-level coverage
|
||||
arrives when Phase D's valuegen builders emit v2 values. `Score::empty` keeps
|
||||
its signature (Timestamp(0) is the ratified unset convention; a
|
||||
creation-time builder waits for a producer, e.g. import).
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -46,14 +46,20 @@ struct Corpus {
|
|||
regions: Vec<Vec<u8>>,
|
||||
/// Valid **frozen v0** whole-`Score` encodings — genuine major-0 wire bytes
|
||||
/// (via [`crate::codec::encode_v0_score`]), to exercise the strict v0
|
||||
/// migration path with real v0 inputs rather than only mutated v1 bytes.
|
||||
/// migration path with real v0 inputs rather than only mutated current
|
||||
/// bytes.
|
||||
v0_scores: Vec<Vec<u8>>,
|
||||
/// Valid **frozen v1** whole-`Score` encodings (via
|
||||
/// [`crate::codec::encode_v1_score`]) — the schema-major-2 migration's
|
||||
/// input form.
|
||||
v1_scores: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn build_corpus(rng: &mut SplitMix64) -> Corpus {
|
||||
let mut scores = Vec::new();
|
||||
let mut regions = Vec::new();
|
||||
let mut v0_scores = Vec::new();
|
||||
let mut v1_scores = Vec::new();
|
||||
for i in 0..12u64 {
|
||||
let seed = rng.next_u64();
|
||||
let score = if i % 2 == 0 {
|
||||
|
|
@ -65,12 +71,14 @@ fn build_corpus(rng: &mut SplitMix64) -> Corpus {
|
|||
regions.push(region.canonical_bytes());
|
||||
}
|
||||
v0_scores.push(crate::codec::encode_v0_score(&score));
|
||||
v1_scores.push(crate::codec::encode_v1_score(&score));
|
||||
scores.push(score.canonical_bytes());
|
||||
}
|
||||
Corpus {
|
||||
scores,
|
||||
regions,
|
||||
v0_scores,
|
||||
v1_scores,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,15 +192,22 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) {
|
|||
// The current-layout decoder: must not panic; an Ok must round-trip.
|
||||
check_score(Score::decode_canonical(&bytes), &bytes);
|
||||
|
||||
// The schema-version dispatch seam. Major 1 is the current layout; major
|
||||
// 0 runs the frozen `decode_v0_score` migration; an arbitrary major
|
||||
// exercises the defensive out-of-accept-set path.
|
||||
let _ = Score::decode_canonical_versioned(&bytes, 1);
|
||||
// The v0 migration default-fills the schema-major-1 fields, so it does
|
||||
// not round-trip to the *v1* form — but it is strictly canonical over the
|
||||
// **v0 wire form**: an accepted input re-encodes to itself via the frozen
|
||||
// v0 encoder. This proves non-canonical rejection on the v0 path, not
|
||||
// The schema-version dispatch seam. Major 2 is the current layout;
|
||||
// majors 1 and 0 run the frozen migrations; an arbitrary major
|
||||
// exercises the defensive out-of-accept-set path. Each migration
|
||||
// default-fills the appended fields, so it does not round-trip to the
|
||||
// *current* form — but each is strictly canonical over its OWN wire
|
||||
// form: an accepted input re-encodes to itself via the frozen encoder.
|
||||
// This proves non-canonical rejection on every versioned path, not
|
||||
// just the absence of a panic.
|
||||
let _ = Score::decode_canonical_versioned(&bytes, 2);
|
||||
if let Ok(v1_score) = Score::decode_canonical_versioned(&bytes, 1) {
|
||||
assert_eq!(
|
||||
crate::codec::encode_v1_score(&v1_score),
|
||||
bytes,
|
||||
"the v1 migration accepted a non-canonical v1 byte string"
|
||||
);
|
||||
}
|
||||
if let Ok(v0_score) = Score::decode_canonical_versioned(&bytes, 0) {
|
||||
assert_eq!(
|
||||
crate::codec::encode_v0_score(&v0_score),
|
||||
|
|
@ -230,31 +245,53 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) {
|
|||
}
|
||||
}
|
||||
|
||||
// Every ~4th iteration, feed a *genuine* v0-form encoding (mutated) to
|
||||
// the frozen major-0 migration, so its strict v0 canonicality is hit
|
||||
// with real v0 bytes — an accepted input must re-encode to itself in the
|
||||
// v0 wire form, and an unmutated v0 encoding must always be accepted.
|
||||
// Every ~4th iteration, feed a *genuine* frozen-form encoding
|
||||
// (mutated) to its migration path — both the v0 and v1 wire forms —
|
||||
// so each strict canonicality guard is hit with real bytes of its own
|
||||
// major. An UNMUTATED frozen encoding MUST decode Ok (enforced, not
|
||||
// just commented); an accepted input must re-encode to itself.
|
||||
if rng.next_u64() % 4 == 0 {
|
||||
let mut v0 =
|
||||
corpus.v0_scores[(rng.next_u64() as usize) % corpus.v0_scores.len()].clone();
|
||||
match rng.next_u64() % 4 {
|
||||
0 => {} // unmutated: must decode Ok and round-trip the v0 form.
|
||||
1 => {
|
||||
let k = 1 + (rng.next_u64() % 4) as usize;
|
||||
substitute(&mut rng, &mut v0, k);
|
||||
type Reenc = fn(&Score) -> Vec<u8>;
|
||||
let forms: [(&[Vec<u8>], u16, Reenc, &str); 2] = [
|
||||
(
|
||||
&corpus.v0_scores,
|
||||
0,
|
||||
crate::codec::encode_v0_score as Reenc,
|
||||
"v0",
|
||||
),
|
||||
(
|
||||
&corpus.v1_scores,
|
||||
1,
|
||||
crate::codec::encode_v1_score as Reenc,
|
||||
"v1",
|
||||
),
|
||||
];
|
||||
for (pool, major, reenc, label) in forms {
|
||||
let mut bytes = pool[(rng.next_u64() as usize) % pool.len()].clone();
|
||||
let mutation = rng.next_u64() % 4;
|
||||
match mutation {
|
||||
0 => {} // unmutated: must decode Ok (asserted below).
|
||||
1 => {
|
||||
let k = 1 + (rng.next_u64() % 4) as usize;
|
||||
substitute(&mut rng, &mut bytes, k);
|
||||
}
|
||||
2 => {
|
||||
let t = (rng.next_u64() as usize) % (bytes.len() + 1);
|
||||
bytes.truncate(t);
|
||||
}
|
||||
_ => corrupt_length_prefix(&mut rng, &mut bytes),
|
||||
}
|
||||
2 => {
|
||||
let t = (rng.next_u64() as usize) % (v0.len() + 1);
|
||||
v0.truncate(t);
|
||||
match Score::decode_canonical_versioned(&bytes, major) {
|
||||
Ok(score) => assert_eq!(
|
||||
reenc(&score),
|
||||
bytes,
|
||||
"the {label} migration accepted a non-canonical {label} byte string"
|
||||
),
|
||||
Err(_) => assert_ne!(
|
||||
mutation, 0,
|
||||
"an unmutated genuine {label} encoding must decode Ok"
|
||||
),
|
||||
}
|
||||
_ => corrupt_length_prefix(&mut rng, &mut v0),
|
||||
}
|
||||
if let Ok(score) = Score::decode_canonical_versioned(&v0, 0) {
|
||||
assert_eq!(
|
||||
crate::codec::encode_v0_score(&score),
|
||||
v0,
|
||||
"the v0 migration accepted a non-canonical v0 byte string"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,11 +114,7 @@ pub fn valid_score(seed: u64) -> Score {
|
|||
let staff_id: StaffId = idc.mint();
|
||||
let instrument: InstrumentId = idc.mint();
|
||||
// Declare the instrument so the staff's reference resolves (invariant 10).
|
||||
instruments.push(Instrument {
|
||||
id: instrument,
|
||||
name: String::from("instrument"),
|
||||
range: None,
|
||||
});
|
||||
instruments.push(Instrument::new(instrument, String::from("instrument")));
|
||||
staves.push(Staff {
|
||||
id: staff_id,
|
||||
name: String::from("staff"),
|
||||
|
|
@ -126,6 +122,7 @@ pub fn valid_score(seed: u64) -> Score {
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
});
|
||||
staff_extent.push(staff_id);
|
||||
|
||||
|
|
@ -221,11 +218,7 @@ pub fn valid_score_rich(seed: u64) -> Score {
|
|||
-> StaffId {
|
||||
let id: StaffId = idc.mint();
|
||||
let instrument: InstrumentId = idc.mint();
|
||||
instruments.push(Instrument {
|
||||
id: instrument,
|
||||
name: String::from("instrument"),
|
||||
range: None,
|
||||
});
|
||||
instruments.push(Instrument::new(instrument, String::from("instrument")));
|
||||
staves.push(Staff {
|
||||
id,
|
||||
name: String::from("staff"),
|
||||
|
|
@ -233,6 +226,7 @@ pub fn valid_score_rich(seed: u64) -> Score {
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
});
|
||||
id
|
||||
};
|
||||
|
|
@ -301,6 +295,7 @@ pub fn valid_score_rich(seed: u64) -> Score {
|
|||
end_event: triplet_members[1],
|
||||
pitch_pairing: None,
|
||||
class: TieClass::Standard,
|
||||
style: Default::default(),
|
||||
});
|
||||
// The first triplet member is an eighth in a 3:2 triplet, so its sounding
|
||||
// duration is 1/8 × 2/3 = 1/12 — matching the event's duration (invariant 15).
|
||||
|
|
@ -325,6 +320,8 @@ pub fn valid_score_rich(seed: u64) -> Score {
|
|||
time: WallClockTime(10),
|
||||
},
|
||||
staves: vec![staff_a],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
});
|
||||
cross_cutting.markers.push(Marker {
|
||||
id: idc.mint::<MarkerId>(),
|
||||
|
|
@ -587,6 +584,7 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score {
|
|||
instrument: s.identity.mint(),
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
});
|
||||
s.canvas.regions[0].staff_extent.staves.push(staff2);
|
||||
s.canvas.regions[0]
|
||||
|
|
@ -610,6 +608,8 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score {
|
|||
time: WallClockTime(10),
|
||||
},
|
||||
staves: vec![staff],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
});
|
||||
}
|
||||
CrossCuttingRefsResolve => {
|
||||
|
|
@ -619,6 +619,9 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score {
|
|||
id: SlurId::new(replica, 1),
|
||||
start_event: ghost,
|
||||
end_event: ghost,
|
||||
kind: Default::default(),
|
||||
curvature_override: None,
|
||||
style: Default::default(),
|
||||
});
|
||||
}
|
||||
UniqueIdentifiers => {
|
||||
|
|
@ -700,6 +703,7 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score {
|
|||
end_event: e1,
|
||||
pitch_pairing: Some(vec![(ghost, end_pid)]),
|
||||
class: TieClass::Editorial,
|
||||
style: Default::default(),
|
||||
});
|
||||
}
|
||||
VoiceOriginConsistent => {
|
||||
|
|
|
|||
|
|
@ -43,14 +43,33 @@ pub enum StemDirection {
|
|||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct StaffLineConfiguration {
|
||||
pub line_count: u8,
|
||||
/// Schema major 2 (appended; migration default 1.0).
|
||||
pub line_spacing: SpaceUnit,
|
||||
/// Schema major 2 (appended; migration default `Solid`).
|
||||
pub line_style: LineStyle,
|
||||
/// Schema major 2 (appended; migration default `None`). A per-staff
|
||||
/// bracket adornment, distinct from StaffGroup-level bracketing.
|
||||
pub bracket: Option<StaffBracketKind>,
|
||||
}
|
||||
|
||||
impl Default for StaffLineConfiguration {
|
||||
fn default() -> Self {
|
||||
StaffLineConfiguration { line_count: 5 }
|
||||
StaffLineConfiguration {
|
||||
line_count: 5,
|
||||
line_spacing: SpaceUnit::normal(),
|
||||
line_style: LineStyle::Solid,
|
||||
bracket: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-staff bracket adornment (Chapter 5; schema major 2).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum StaffBracketKind {
|
||||
Brace,
|
||||
Bracket,
|
||||
}
|
||||
|
||||
/// The SMuFL clef family a [`Clef`] draws from. The reference pitch each family
|
||||
/// fixes (G4 / F3 / middle C4) is what pins the staff-position mapping.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
|
|
@ -797,6 +816,9 @@ pub struct Staff {
|
|||
pub instrument: InstrumentId,
|
||||
pub default_staff_lines: StaffLineConfiguration,
|
||||
pub group: Option<StaffGroupId>,
|
||||
/// Default clef for new instances of this staff (schema major 2,
|
||||
/// appended last per the wire rule; migration default treble).
|
||||
pub default_clef: Clef,
|
||||
}
|
||||
|
||||
/// The spatial root of the score (Chapter 5 §"The Canvas").
|
||||
|
|
@ -864,12 +886,110 @@ impl Default for CanvasLayoutDefaults {
|
|||
// graphic gestures, lyrics, chord symbols) extends this with the same
|
||||
// reference-resolution discipline.
|
||||
|
||||
/// A dimension in staff spaces (Chapter 5; schema major 2). Staff line
|
||||
/// spacing is relative to the global staff space: 1.0 is a normal-size
|
||||
/// staff, smaller values yield cue/ossia staves. The wire form is the
|
||||
/// wrapped [`CanonicalF64`]'s (the newtype adds no bytes).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SpaceUnit(pub CanonicalF64);
|
||||
|
||||
impl SpaceUnit {
|
||||
/// The normal-size staff spacing, 1.0 — the v1→v2 migration default
|
||||
/// for `StaffLineConfiguration.line_spacing`.
|
||||
pub fn normal() -> Self {
|
||||
SpaceUnit(CanonicalF64::new(1.0).expect("1.0 is finite"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A line drawing style (Chapter 5; schema major 2). Shared by staff
|
||||
/// lines and [`SpanStyle`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub enum LineStyle {
|
||||
#[default]
|
||||
Solid,
|
||||
Dashed,
|
||||
Dotted,
|
||||
}
|
||||
|
||||
/// The class of a slur (Chapter 5 §"Slurs"; schema major 2). The
|
||||
/// v1→v2 migration default is `Legato`.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub enum SlurKind {
|
||||
/// An ordinary legato slur.
|
||||
#[default]
|
||||
Legato,
|
||||
/// A phrase mark (typically longer, over sub-phrases).
|
||||
Phrase,
|
||||
/// An articulation slur (e.g., over a two-note sigh figure).
|
||||
Articulation,
|
||||
/// An editorial slur (rendered distinctly, e.g., dashed).
|
||||
Editorial,
|
||||
}
|
||||
|
||||
/// Which side of the notes a curve arcs toward (Chapter 5; schema
|
||||
/// major 2).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum CurveDirection {
|
||||
Above,
|
||||
Below,
|
||||
}
|
||||
|
||||
/// An authored curvature override (Chapter 5 §"Slurs"; schema major 2).
|
||||
/// The engraver computes default curvature; each present field
|
||||
/// overrides that component. Consumed by the Standard engraving tier;
|
||||
/// stored and preserved at every tier.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct CurvatureOverride {
|
||||
pub direction: Option<CurveDirection>,
|
||||
/// Arc height at the apex.
|
||||
pub height: Option<SpaceUnit>,
|
||||
}
|
||||
|
||||
/// The visual style of a spanning mark (Chapter 5; schema major 2):
|
||||
/// one shared record for `Slur.style`, `Tie.style`, and
|
||||
/// `Spanner.style`. Defaults: solid, engraver-chosen thickness.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct SpanStyle {
|
||||
pub line: LineStyle,
|
||||
/// Line thickness; `None` = the engraver's default.
|
||||
pub thickness: Option<SpaceUnit>,
|
||||
}
|
||||
|
||||
/// A slur / phrase mark over a span of events (Chapter 5 §"Slurs").
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Slur {
|
||||
pub id: SlurId,
|
||||
pub start_event: crate::ids::EventId,
|
||||
pub end_event: crate::ids::EventId,
|
||||
/// Schema major 2 (appended; migration default `Legato`).
|
||||
pub kind: SlurKind,
|
||||
/// Schema major 2 (appended; migration default `None`).
|
||||
pub curvature_override: Option<CurvatureOverride>,
|
||||
/// Schema major 2 (appended; migration default `SpanStyle::default()`).
|
||||
pub style: SpanStyle,
|
||||
}
|
||||
|
||||
/// A beam segment at a deeper subdivision level (Chapter 5 §"Beams";
|
||||
/// schema major 2): a contiguous subset of the owning beam's events
|
||||
/// beamed together at `level` (strictly deeper than the owner's
|
||||
/// primary level).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SubBeam {
|
||||
pub level: u8,
|
||||
pub events: Vec<crate::ids::EventId>,
|
||||
}
|
||||
|
||||
/// An authored beam-geometry override (Chapter 5 §"Beams"; schema
|
||||
/// major 2). Each present field overrides the engraver's computed
|
||||
/// geometry. Consumed by the Standard engraving tier; stored and
|
||||
/// preserved at every tier.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct BeamGeometryOverride {
|
||||
/// Beam slope: staff spaces of rise per staff space of run
|
||||
/// (dimensionless, hence not a [`SpaceUnit`]).
|
||||
pub slope: Option<CanonicalF64>,
|
||||
/// Vertical displacement from the default placement (positive = up).
|
||||
pub offset: Option<SpaceUnit>,
|
||||
}
|
||||
|
||||
/// A beam over a sequence of events (Chapter 5 §"Beams").
|
||||
|
|
@ -878,6 +998,65 @@ pub struct Beam {
|
|||
pub id: BeamId,
|
||||
pub events: Vec<crate::ids::EventId>,
|
||||
pub level: u8,
|
||||
/// Schema major 2 (appended; migration default empty).
|
||||
pub sub_beams: Vec<SubBeam>,
|
||||
/// Schema major 2 (appended; migration default `None`).
|
||||
pub geometry_override: Option<BeamGeometryOverride>,
|
||||
}
|
||||
|
||||
/// Hairpin orientation (Chapter 5 §"Spanners"; schema major 2).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum HairpinDirection {
|
||||
Crescendo,
|
||||
Diminuendo,
|
||||
}
|
||||
|
||||
/// Octave-line displacement in signed octaves (Chapter 5 §"Spanners";
|
||||
/// schema major 2): +1 = 8va, -1 = 8vb, +2 = 15ma, -2 = 15mb. Zero is
|
||||
/// representable but degenerate; the authoring advisory layer flags
|
||||
/// it, reduction does not.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct OctaveOffset(pub i8);
|
||||
|
||||
/// Pedal-line kind (Chapter 5 §"Spanners"; schema major 2).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum PedalKind {
|
||||
Sustain,
|
||||
Sostenuto,
|
||||
UnaCorda,
|
||||
}
|
||||
|
||||
/// A text line's content (Chapter 5 §"Spanners"; schema major 2); the
|
||||
/// dash pattern comes from the spanner's style.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct TextLineDefinition {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A bracket spanner's shape (Chapter 5 §"Spanners"; schema major 2).
|
||||
/// Growth is by appended variant.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum BracketKind {
|
||||
Square,
|
||||
}
|
||||
|
||||
/// The kind of a spanner (Chapter 5 §"Spanners"; schema major 2).
|
||||
/// `Generic` is deliberately first: it is the v1→v2 migration default
|
||||
/// — a v1 spanner carried no kind, and `Generic` (a plain line or
|
||||
/// bracket) is the honest translation of that absence.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub enum SpannerKind {
|
||||
/// An unclassified spanning mark: renders as a plain line/bracket.
|
||||
#[default]
|
||||
Generic,
|
||||
Hairpin(HairpinDirection),
|
||||
OctaveLine(OctaveOffset),
|
||||
PedalLine(PedalKind),
|
||||
TrillExtension,
|
||||
Glissando,
|
||||
Portamento,
|
||||
TextLine(TextLineDefinition),
|
||||
Bracket(BracketKind),
|
||||
}
|
||||
|
||||
/// A generic spanning mark anchored by time (Chapter 5 §"Spanners").
|
||||
|
|
@ -888,6 +1067,11 @@ pub struct Spanner {
|
|||
pub end: TimeAnchor,
|
||||
/// Which staves this spanner attaches to.
|
||||
pub staves: Vec<StaffId>,
|
||||
/// Schema major 2 (appended after `staves` per the wire rule;
|
||||
/// migration default `Generic`).
|
||||
pub kind: SpannerKind,
|
||||
/// Schema major 2 (appended; migration default `SpanStyle::default()`).
|
||||
pub style: SpanStyle,
|
||||
}
|
||||
|
||||
/// The class of a tie, fixing its validation profile (Chapter 5 §"Ties").
|
||||
|
|
@ -916,6 +1100,8 @@ pub struct Tie {
|
|||
/// pitches by enharmonic matching in pitch-id-ascending order.
|
||||
pub pitch_pairing: Option<Vec<(PitchId, PitchId)>>,
|
||||
pub class: TieClass,
|
||||
/// Schema major 2 (appended; migration default `SpanStyle::default()`).
|
||||
pub style: SpanStyle,
|
||||
}
|
||||
|
||||
/// The actual:notated ratio of a tuplet (Chapter 3 §"Tuplets"). Built only
|
||||
|
|
@ -988,6 +1174,46 @@ pub struct Marker {
|
|||
pub anchor: TimeAnchor,
|
||||
}
|
||||
|
||||
/// The kind of a repeat structure (Chapter 5 §"Repeat Structures";
|
||||
/// schema major 2). The v1→v2 migration default is
|
||||
/// `SimpleRepeat { count: 2 }`: a v1 repeat *meant* a repeat, and
|
||||
/// playing the span twice is the conventional semantics of an
|
||||
/// unadorned repeat sign.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum RepeatKind {
|
||||
SimpleRepeat {
|
||||
count: u32,
|
||||
},
|
||||
DaCapo {
|
||||
end_target: TimeAnchor,
|
||||
},
|
||||
DalSegno {
|
||||
segno: TimeAnchor,
|
||||
end_target: TimeAnchor,
|
||||
},
|
||||
Volta,
|
||||
}
|
||||
|
||||
impl RepeatKind {
|
||||
/// The v1→v2 migration default (Binary Format §Schema Major 2).
|
||||
pub const fn migration_default() -> Self {
|
||||
RepeatKind::SimpleRepeat { count: 2 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One volta bracket (Chapter 5 §"Repeat Structures"; schema major 2):
|
||||
/// the passes it applies on and the time span it covers. The `endings`
|
||||
/// constraints (non-empty, 1-based, strictly ascending) are advisory —
|
||||
/// decoders and reduction accept violations, the authoring validation
|
||||
/// layer flags them.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Volta {
|
||||
/// The pass numbers this ending plays on (1-based), ascending.
|
||||
pub endings: Vec<u32>,
|
||||
pub start: TimeAnchor,
|
||||
pub end: TimeAnchor,
|
||||
}
|
||||
|
||||
/// A repeat structure: simple repeat, da capo, dal segno, volta (Chapter 5
|
||||
/// §"Repeat Structures"). Spanned by two time anchors.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
|
|
@ -995,6 +1221,11 @@ pub struct RepeatStructure {
|
|||
pub id: RepeatStructureId,
|
||||
pub start: TimeAnchor,
|
||||
pub end: TimeAnchor,
|
||||
/// Schema major 2 (appended; migration default
|
||||
/// [`RepeatKind::migration_default`]).
|
||||
pub kind: RepeatKind,
|
||||
/// Schema major 2 (appended; migration default empty).
|
||||
pub voltas: Vec<Volta>,
|
||||
}
|
||||
|
||||
/// An analytical annotation (Roman numeral, form label, …) (Chapter 5
|
||||
|
|
@ -1187,6 +1418,35 @@ pub struct DecompositionAttachment {
|
|||
// later companions; this baseline models the identity- and reference-bearing
|
||||
// skeleton and leaves the rest as documented placeholders.
|
||||
|
||||
/// A calendar timestamp (Chapter 5 §"Score Metadata"; schema major 2):
|
||||
/// nanoseconds since the Unix epoch, UTC, no zone. Distinct from
|
||||
/// [`crate::WallClockTime`], which is *performance* time within a
|
||||
/// score. Zero is the "unset" convention. Strictly authored
|
||||
/// (`req:graph:metadata-timestamps`): nothing writes these implicitly.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct Timestamp(pub i64);
|
||||
|
||||
/// The value of an additional metadata entry (Chapter 5 §"Score
|
||||
/// Metadata"; schema major 2). Closed small union; growth by appended
|
||||
/// variant.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum MetadataValue {
|
||||
Text(String),
|
||||
Integer(i64),
|
||||
Flag(bool),
|
||||
}
|
||||
|
||||
/// One additional metadata entry (Chapter 5 §"Score Metadata"; schema
|
||||
/// major 2). `ScoreMetadata.additional` is an ordered authored *list*,
|
||||
/// not a map: order is preserved verbatim and duplicate keys are
|
||||
/// permitted (foreign formats carry repeated keys); map-seeking
|
||||
/// consumers take the first entry per key.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct MetadataEntry {
|
||||
pub key: String,
|
||||
pub value: MetadataValue,
|
||||
}
|
||||
|
||||
/// Bibliographic and authorship metadata (Chapter 5 §"Score Metadata"). The
|
||||
/// structure is deliberately small.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
|
|
@ -1194,6 +1454,51 @@ pub struct ScoreMetadata {
|
|||
pub title: Option<String>,
|
||||
pub composer: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
/// Schema major 2 (appended; migration default `None`).
|
||||
pub subtitle: Option<String>,
|
||||
/// Schema major 2 (appended; migration default `None`).
|
||||
pub lyricist: Option<String>,
|
||||
/// Schema major 2 (appended; migration default `None`).
|
||||
pub arranger: Option<String>,
|
||||
/// Schema major 2 (appended; migration default unset/zero).
|
||||
pub creation_timestamp: Timestamp,
|
||||
/// Schema major 2 (appended; migration default unset/zero).
|
||||
pub modification_timestamp: Timestamp,
|
||||
/// Schema major 2 (appended; migration default empty).
|
||||
pub additional: Vec<MetadataEntry>,
|
||||
}
|
||||
|
||||
/// Opaque sound configuration (Chapter 5 §"Instruments"; schema
|
||||
/// major 2). The audio engine specification owns the structure; the
|
||||
/// core stores the bytes verbatim and never interprets them.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct SoundConfiguration(pub Vec<u8>);
|
||||
|
||||
/// A written-versus-sounding transposition (Chapter 5 §"Instruments";
|
||||
/// schema major 2). Structural only: the diatonic and chromatic step
|
||||
/// counts of the interval (e.g., B-flat clarinet = -1 diatonic,
|
||||
/// -2 chromatic). Semantically ADVISORY until the Chapter 4 tuning
|
||||
/// catalog pins interval algebra (the P12-K2 discipline): nothing in
|
||||
/// the core resolves it to frequencies or respells through it yet.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct TranspositionInterval {
|
||||
pub diatonic_steps: i32,
|
||||
pub chromatic_steps: i32,
|
||||
}
|
||||
|
||||
/// One playable member of an unpitched instrument (Chapter 5
|
||||
/// §"Instruments"; schema major 2). `member` is an instrument-scoped
|
||||
/// small value (not a 128-bit object id); resolution from
|
||||
/// `UnpitchedEvent.instrument_member` is by first match in list order,
|
||||
/// and a no-match is tolerated (every pre-major-2 instrument has an
|
||||
/// empty member list while its events carry member values) — the
|
||||
/// event's own `staff_position` governs placement either way; the
|
||||
/// member's is the authoring default copied onto new events.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct UnpitchedMember {
|
||||
pub member: crate::event::UnpitchedMemberId,
|
||||
pub name: String,
|
||||
pub staff_position: crate::event::StaffPosition,
|
||||
}
|
||||
|
||||
/// An abstract instrument definition (Chapter 5 §"Instruments"). Baseline: the
|
||||
|
|
@ -1209,6 +1514,38 @@ pub struct Instrument {
|
|||
/// spanning-frame candidate outside the range trips the advisory check in
|
||||
/// authoring mode only (see [`PitchRange::contains`]).
|
||||
pub range: Option<PitchRange>,
|
||||
|
||||
/// Schema major 2 (appended after the major-1 order per the wire
|
||||
/// rule; migration default `None`).
|
||||
pub abbreviation: Option<String>,
|
||||
/// Schema major 2 (appended; migration default empty).
|
||||
pub sound_config: SoundConfiguration,
|
||||
/// Schema major 2 (appended; migration default `None`).
|
||||
pub transposition: Option<TranspositionInterval>,
|
||||
/// Schema major 2 (appended; migration default treble).
|
||||
pub default_clef: Clef,
|
||||
/// Schema major 2 (appended; migration default the 5-line default).
|
||||
pub default_staff_lines: StaffLineConfiguration,
|
||||
/// Schema major 2 (appended; migration default empty).
|
||||
pub unpitched_members: Vec<UnpitchedMember>,
|
||||
}
|
||||
|
||||
impl Instrument {
|
||||
/// A minimal instrument: identity and name, every other field at its
|
||||
/// canonical default (the schema-major-2 migration defaults).
|
||||
pub fn new(id: InstrumentId, name: impl Into<String>) -> Self {
|
||||
Instrument {
|
||||
id,
|
||||
name: name.into(),
|
||||
range: None,
|
||||
abbreviation: None,
|
||||
sound_config: SoundConfiguration::default(),
|
||||
transposition: None,
|
||||
default_clef: Clef::treble(),
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
unpitched_members: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of a staff grouping (Chapter 5 §"Top-Level Score Structure").
|
||||
|
|
|
|||
|
|
@ -808,6 +808,20 @@ impl<'a> GraphIndex<'a> {
|
|||
for rp in &cc.repeats {
|
||||
anchors.push(&rp.start);
|
||||
anchors.push(&rp.end);
|
||||
// Schema major 2: the kind and volta anchors are anchors too.
|
||||
match &rp.kind {
|
||||
crate::graph::RepeatKind::DaCapo { end_target } => anchors.push(end_target),
|
||||
crate::graph::RepeatKind::DalSegno { segno, end_target } => {
|
||||
anchors.push(segno);
|
||||
anchors.push(end_target);
|
||||
}
|
||||
crate::graph::RepeatKind::SimpleRepeat { .. } | crate::graph::RepeatKind::Volta => {
|
||||
}
|
||||
}
|
||||
for v in &rp.voltas {
|
||||
anchors.push(&v.start);
|
||||
anchors.push(&v.end);
|
||||
}
|
||||
}
|
||||
for cs in &cc.chord_symbols {
|
||||
anchors.push(&cs.anchor);
|
||||
|
|
@ -931,6 +945,15 @@ impl<'a> GraphIndex<'a> {
|
|||
format!("beam {:?} event {:?} dangling", b.id, e),
|
||||
);
|
||||
}
|
||||
// Schema major 2: sub-beam member events are references too.
|
||||
for sb in &b.sub_beams {
|
||||
for e in &sb.events {
|
||||
flag(
|
||||
live_event(e),
|
||||
format!("beam {:?} sub-beam event {:?} dangling", b.id, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for tp in &self.score.cross_cutting.tuplets {
|
||||
for e in &tp.members {
|
||||
|
|
@ -990,6 +1013,28 @@ impl<'a> GraphIndex<'a> {
|
|||
self.anchor_target_exists(&rp.start) && self.anchor_target_exists(&rp.end),
|
||||
format!("repeat {:?} anchor target dangling", rp.id),
|
||||
);
|
||||
// Schema major 2: the kind's jump anchors and each volta's span.
|
||||
let kind_ok = match &rp.kind {
|
||||
crate::graph::RepeatKind::DaCapo { end_target } => {
|
||||
self.anchor_target_exists(end_target)
|
||||
}
|
||||
crate::graph::RepeatKind::DalSegno { segno, end_target } => {
|
||||
self.anchor_target_exists(segno) && self.anchor_target_exists(end_target)
|
||||
}
|
||||
crate::graph::RepeatKind::SimpleRepeat { .. } | crate::graph::RepeatKind::Volta => {
|
||||
true
|
||||
}
|
||||
};
|
||||
flag(
|
||||
kind_ok,
|
||||
format!("repeat {:?} kind anchor target dangling", rp.id),
|
||||
);
|
||||
for v in &rp.voltas {
|
||||
flag(
|
||||
self.anchor_target_exists(&v.start) && self.anchor_target_exists(&v.end),
|
||||
format!("repeat {:?} volta anchor target dangling", rp.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
for cs in &cc.chord_symbols {
|
||||
flag(
|
||||
|
|
@ -2473,11 +2518,11 @@ mod review_fix_tests {
|
|||
use crate::event::{Event, PitchedEvent, StemConfiguration};
|
||||
use crate::generators::valid_score;
|
||||
use crate::graph::{
|
||||
derive_promoted_voice_id, MetricTimeModel, ProportionalTimeModel, Region, RegionContent,
|
||||
Spanner, StaffBasedContent, StaffExtent, StaffInstance, Tie, TieClass, TimeExtent, Voice,
|
||||
VoiceOrigin,
|
||||
derive_promoted_voice_id, Beam, MetricTimeModel, ProportionalTimeModel, Region,
|
||||
RegionContent, RepeatKind, RepeatStructure, Spanner, StaffBasedContent, StaffExtent,
|
||||
StaffInstance, SubBeam, Tie, TieClass, TimeExtent, Voice, VoiceOrigin, Volta,
|
||||
};
|
||||
use crate::ids::{OperationId, ReplicaId, SpannerId, TieId};
|
||||
use crate::ids::{BeamId, OperationId, RepeatStructureId, ReplicaId, SpannerId, TieId};
|
||||
use crate::pitch::{
|
||||
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
|
||||
PitchSpacePosition, ScalePosition, TuningReference,
|
||||
|
|
@ -2632,6 +2677,8 @@ mod review_fix_tests {
|
|||
time: WallClockTime(1),
|
||||
},
|
||||
staves: vec![staff],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
};
|
||||
s.cross_cutting.spanners.push(spanner_ok);
|
||||
assert!(!fires(&s, GraphInvariant::AnchorOffsetModel));
|
||||
|
|
@ -2659,10 +2706,77 @@ mod review_fix_tests {
|
|||
time: WallClockTime(0),
|
||||
},
|
||||
staves: vec![staff],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
});
|
||||
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inv10_flags_dangling_sub_beam_event() {
|
||||
// Schema major 2: sub-beam member events are references the invariant
|
||||
// must resolve, exactly like the owning beam's events.
|
||||
let mut s = valid_score(3);
|
||||
let live: Vec<_> = s
|
||||
.voices()
|
||||
.flat_map(|(_, _, v)| v.events.clone())
|
||||
.take(2)
|
||||
.collect();
|
||||
let ghost_event = crate::ids::EventId::new(s.identity.replica_id, 9_000_002);
|
||||
s.cross_cutting.beams.push(Beam {
|
||||
id: BeamId::new(s.identity.replica_id, 1),
|
||||
events: live.clone(),
|
||||
level: 1,
|
||||
sub_beams: vec![SubBeam {
|
||||
level: 2,
|
||||
events: vec![ghost_event],
|
||||
}],
|
||||
geometry_override: None,
|
||||
});
|
||||
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inv10_flags_dangling_repeat_kind_and_volta_anchors() {
|
||||
// Schema major 2: a DalSegno's segno/end_target and each volta's span
|
||||
// are anchors the invariant must resolve.
|
||||
let mut s = valid_score(3);
|
||||
let r = s.identity.replica_id;
|
||||
let ghost = crate::ids::EventId::new(r, 9_000_003);
|
||||
let dead_anchor = TimeAnchor::Event {
|
||||
id: ghost,
|
||||
offset: AnchorOffset::Zero,
|
||||
};
|
||||
let ok = TimeAnchor::WallClock {
|
||||
time: WallClockTime(0),
|
||||
};
|
||||
s.cross_cutting.repeats.push(RepeatStructure {
|
||||
id: RepeatStructureId::new(r, 1),
|
||||
start: ok.clone(),
|
||||
end: ok.clone(),
|
||||
kind: RepeatKind::DalSegno {
|
||||
segno: dead_anchor.clone(),
|
||||
end_target: ok.clone(),
|
||||
},
|
||||
voltas: vec![],
|
||||
});
|
||||
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
|
||||
|
||||
let mut s2 = valid_score(3);
|
||||
s2.cross_cutting.repeats.push(RepeatStructure {
|
||||
id: RepeatStructureId::new(r, 2),
|
||||
start: ok.clone(),
|
||||
end: ok.clone(),
|
||||
kind: RepeatKind::Volta,
|
||||
voltas: vec![Volta {
|
||||
endings: vec![1],
|
||||
start: dead_anchor,
|
||||
end: ok,
|
||||
}],
|
||||
});
|
||||
assert!(fires(&s2, GraphInvariant::CrossCuttingRefsResolve));
|
||||
}
|
||||
|
||||
/// Builds a single-voice score with two adjacent pitched chords and returns
|
||||
/// (score, e0, e1) for tie tests.
|
||||
fn two_chord_score(
|
||||
|
|
@ -2719,6 +2833,7 @@ mod review_fix_tests {
|
|||
end_event: e1,
|
||||
pitch_pairing: None,
|
||||
class: TieClass::Standard,
|
||||
style: Default::default(),
|
||||
});
|
||||
assert!(fires(&s, GraphInvariant::TiePairing));
|
||||
|
||||
|
|
@ -2730,6 +2845,7 @@ mod review_fix_tests {
|
|||
end_event: e1,
|
||||
pitch_pairing: None,
|
||||
class: TieClass::Standard,
|
||||
style: Default::default(),
|
||||
});
|
||||
assert!(!fires(&s2, GraphInvariant::TiePairing));
|
||||
}
|
||||
|
|
@ -2751,6 +2867,9 @@ mod review_fix_tests {
|
|||
id: sid,
|
||||
start_event: staff_event,
|
||||
end_event: staff_event,
|
||||
kind: Default::default(),
|
||||
curvature_override: None,
|
||||
style: Default::default(),
|
||||
});
|
||||
}
|
||||
assert!(fires(&s2, GraphInvariant::UniqueIdentifiers));
|
||||
|
|
@ -2857,6 +2976,8 @@ mod review_fix_tests {
|
|||
end: TimeAnchor::WallClock {
|
||||
time: crate::time::WallClockTime(0),
|
||||
},
|
||||
kind: crate::graph::RepeatKind::migration_default(),
|
||||
voltas: Vec::new(),
|
||||
});
|
||||
s.cross_cutting.comments.push(Comment {
|
||||
id: crate::ids::CommentId::new(r, 1),
|
||||
|
|
@ -3114,16 +3235,8 @@ mod review_fix_tests_2 {
|
|||
let mut s = valid_score(71);
|
||||
let r = s.identity.replica_id;
|
||||
let iid = InstrumentId::new(r, 1);
|
||||
s.instruments.push(Instrument {
|
||||
id: iid,
|
||||
name: "a".into(),
|
||||
range: None,
|
||||
});
|
||||
s.instruments.push(Instrument {
|
||||
id: iid,
|
||||
name: "b".into(),
|
||||
range: None,
|
||||
});
|
||||
s.instruments.push(Instrument::new(iid, "a"));
|
||||
s.instruments.push(Instrument::new(iid, "b"));
|
||||
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
|
||||
|
||||
// Score identity in the reserved namespace.
|
||||
|
|
@ -3267,6 +3380,7 @@ mod review_fix_tests_3 {
|
|||
end_event: e1,
|
||||
pitch_pairing: None,
|
||||
class: TieClass::Standard,
|
||||
style: Default::default(),
|
||||
});
|
||||
assert!(
|
||||
!fires(&s, GraphInvariant::TiePairing),
|
||||
|
|
@ -3333,6 +3447,12 @@ mod review_fix_tests_3 {
|
|||
id: InstrumentId::new(ReplicaId::SYSTEM_DERIVED, 1),
|
||||
name: "x".into(),
|
||||
range: None,
|
||||
abbreviation: None,
|
||||
sound_config: Default::default(),
|
||||
transposition: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
default_staff_lines: Default::default(),
|
||||
unpitched_members: Vec::new(),
|
||||
});
|
||||
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
|
||||
}
|
||||
|
|
@ -3370,6 +3490,7 @@ mod review_fix_tests_3 {
|
|||
instrument: s.staves[0].instrument,
|
||||
default_staff_lines: Default::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
});
|
||||
inst.staff = staff2;
|
||||
inst.voices.push(voice);
|
||||
|
|
@ -3424,6 +3545,8 @@ mod review_fix_tests_3 {
|
|||
time: WallClockTime(0),
|
||||
},
|
||||
staves: vec![staff2],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
});
|
||||
assert!(fires(&s, GraphInvariant::AnchorOffsetModel));
|
||||
// A wall-clock offset matches the event's clock -> ok.
|
||||
|
|
@ -3657,11 +3780,8 @@ mod review_fix_tests_4 {
|
|||
// A fresh staff Y (declared) with its instrument.
|
||||
let staff_y = s.identity.mint();
|
||||
let instr = s.identity.mint();
|
||||
s.instruments.push(crate::graph::Instrument {
|
||||
id: instr,
|
||||
name: "y".into(),
|
||||
range: None,
|
||||
});
|
||||
s.instruments
|
||||
.push(crate::graph::Instrument::new(instr, "y"));
|
||||
s.staves.push(crate::graph::Staff {
|
||||
id: staff_y,
|
||||
name: "Y".into(),
|
||||
|
|
@ -3669,6 +3789,7 @@ mod review_fix_tests_4 {
|
|||
instrument: instr,
|
||||
default_staff_lines: Default::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
});
|
||||
let mk_region = |s: &mut Score, start: TimeAnchor, end: TimeAnchor| Region {
|
||||
id: s.identity.mint(),
|
||||
|
|
|
|||
|
|
@ -102,17 +102,20 @@ pub use event::{
|
|||
pub use graph::{
|
||||
derive_promoted_voice_id, AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer,
|
||||
AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam,
|
||||
BeatGroup, Canvas, CanvasLayoutDefaults, CanvasMargins, CanvasSize, ChordSymbol, Clef,
|
||||
ClefChange, ClefShape, Comment, CoordinateDiscipline, CrossCuttingRegistry,
|
||||
DecompositionAttachment, DecompositionSource, EventOrderingDAG, GestureAnchoring,
|
||||
GraphicContent, GraphicGesture, GraphicObject, Instrument, KeySignature, KeySignatureChange,
|
||||
LyricLine, Marker, Measure, MeasureNumberVisibility, MeterChange, MetricGrid, MetricTimeModel,
|
||||
NotatedComponent, NoteValue, PartDefinition, PowerOfTwo, ProportionalTimeModel, Region,
|
||||
RegionContent, RegionTimeModel, RepeatStructure, Score, ScoreMetadata, ScoreTuningContext,
|
||||
Slur, Spanner, Staff, StaffBasedContent, StaffExtent, StaffGroup, StaffGroupKind,
|
||||
StaffInstance, StaffLineConfiguration, StemDirection, TempoMapReference, Tie, TieClass,
|
||||
TimeExtent, TimeSignature, TimeSignatureDisplay, Tuplet, TupletRatio, ViewDefinition, Voice,
|
||||
VoiceOrigin,
|
||||
BeamGeometryOverride, BeatGroup, BracketKind, Canvas, CanvasLayoutDefaults, CanvasMargins,
|
||||
CanvasSize, ChordSymbol, Clef, ClefChange, ClefShape, Comment, CoordinateDiscipline,
|
||||
CrossCuttingRegistry, CurvatureOverride, CurveDirection, DecompositionAttachment,
|
||||
DecompositionSource, EventOrderingDAG, GestureAnchoring, GraphicContent, GraphicGesture,
|
||||
GraphicObject, HairpinDirection, Instrument, KeySignature, KeySignatureChange, LineStyle,
|
||||
LyricLine, Marker, Measure, MeasureNumberVisibility, MetadataEntry, MetadataValue, MeterChange,
|
||||
MetricGrid, MetricTimeModel, NotatedComponent, NoteValue, OctaveOffset, PartDefinition,
|
||||
PedalKind, PowerOfTwo, ProportionalTimeModel, Region, RegionContent, RegionTimeModel,
|
||||
RepeatKind, RepeatStructure, Score, ScoreMetadata, ScoreTuningContext, Slur, SlurKind,
|
||||
SoundConfiguration, SpaceUnit, SpanStyle, Spanner, SpannerKind, Staff, StaffBasedContent,
|
||||
StaffBracketKind, StaffExtent, StaffGroup, StaffGroupKind, StaffInstance,
|
||||
StaffLineConfiguration, StemDirection, SubBeam, TempoMapReference, TextLineDefinition, Tie,
|
||||
TieClass, TimeExtent, TimeSignature, TimeSignatureDisplay, Timestamp, TranspositionInterval,
|
||||
Tuplet, TupletRatio, UnpitchedMember, ViewDefinition, Voice, VoiceOrigin, Volta,
|
||||
};
|
||||
|
||||
pub use tempo::{
|
||||
|
|
|
|||
|
|
@ -177,12 +177,9 @@ fn metric_score(
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
}];
|
||||
score.instruments = vec![Instrument {
|
||||
id: instrument,
|
||||
name: String::from("Test"),
|
||||
range: None,
|
||||
}];
|
||||
score.instruments = vec![Instrument::new(instrument, String::from("Test"))];
|
||||
score.cross_cutting.tuplets = tuplets;
|
||||
score.events = arena;
|
||||
score.canvas = Canvas {
|
||||
|
|
@ -785,12 +782,9 @@ fn nonmetric_region_defers_decomposition_but_still_spells() {
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: crate::graph::Clef::treble(),
|
||||
}];
|
||||
score.instruments = vec![Instrument {
|
||||
id: instrument,
|
||||
name: "T".into(),
|
||||
range: None,
|
||||
}];
|
||||
score.instruments = vec![Instrument::new(instrument, "T")];
|
||||
score.events = arena;
|
||||
score.canvas = Canvas {
|
||||
regions: vec![region_obj],
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
use epiphany_core::{
|
||||
check_invariants, derive_promoted_voice_id, generators, AcousticPitch, AcousticRealization,
|
||||
Canvas, CmnNominal, Event, EventArena, EventDuration, EventPosition, GraphInvariant,
|
||||
IdentifiedPitch, IdentityContext, Instrument, InstrumentId, MusicalDuration, MusicalPosition,
|
||||
OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, RationalTime,
|
||||
Region, RegionContent, RegionTimeModel, ReplicaId, ScalePosition, Score, StaffBasedContent,
|
||||
IdentifiedPitch, IdentityContext, InstrumentId, MusicalDuration, MusicalPosition, OperationId,
|
||||
Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, RationalTime, Region,
|
||||
RegionContent, RegionTimeModel, ReplicaId, ScalePosition, Score, StaffBasedContent,
|
||||
StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, TimeAnchor, TimeExtent,
|
||||
TuningReference, Voice, WallClockTime,
|
||||
};
|
||||
|
|
@ -93,11 +93,7 @@ fn hand_built_score() -> Score {
|
|||
|
||||
let mut score = Score::empty(idc.clone());
|
||||
score.identity = idc;
|
||||
score.instruments = vec![Instrument {
|
||||
id: instrument,
|
||||
name: "Flute".into(),
|
||||
range: None,
|
||||
}];
|
||||
score.instruments = vec![epiphany_core::Instrument::new(instrument, "Flute")];
|
||||
score.staves = vec![Staff {
|
||||
id: staff_id,
|
||||
name: "Flute 1".into(),
|
||||
|
|
@ -105,6 +101,7 @@ fn hand_built_score() -> Score {
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: epiphany_core::Clef::treble(),
|
||||
}];
|
||||
score.events = arena;
|
||||
score.canvas = Canvas {
|
||||
|
|
|
|||
|
|
@ -1446,6 +1446,8 @@ mod tests {
|
|||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
staves: vec![first_staff, second_staff],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
});
|
||||
|
||||
let logical = to_logical(&score);
|
||||
|
|
|
|||
|
|
@ -975,3 +975,35 @@ same split previously read `TargetMissing` vs a silent `Applied` rewrite) and
|
|||
is a ModifyEvent-introduction question — whether a replacement value may
|
||||
introduce never-minted pitch ids at all — batched for Pass 13, not
|
||||
improvised here.
|
||||
|
||||
## Schema major 2: minimal stamping (landed with core Phase B, deliberately)
|
||||
|
||||
The live codec change makes CrossCutting/Staff/Metadata payload bytes v2
|
||||
immediately, so the honest stamps land in the SAME commit rather than a
|
||||
later phase (the major-1 D1/D2 byte-shim approach would have needed v1
|
||||
shims for nine transitively-embedded types, all throwaway).
|
||||
`OperationKind::schema_major` implements the ratified **minimal-stamping**
|
||||
rule (Binary Format §Schema Major 2): Create/ModifyCrossCutting, CreateStaff,
|
||||
SetMetadata → always 2 (mandatory appends); CreateRegion → 2 iff a carried
|
||||
staff instance bears Some(staff_lines_override) else 1; CreateStaffInstance/
|
||||
SetStaffLayout → 2 iff Some(override) else 0 (None encodes byte-identically
|
||||
to the prior major). Locked by `schema_majors_follow_the_minimal_stamping_rule`.
|
||||
`the_canonical_base_is_byte_identical_across_data_model_majors` golden-locks
|
||||
a seeded reduction's MaterializedState bytes — the companion's SHOULD that
|
||||
the canonical base never moves across data-model majors.
|
||||
|
||||
**Review sharpening — the op-payload migrate-on-read deferral, stated
|
||||
precisely:** with the live codecs at v2, op-payload BYTES from a pre-major-2
|
||||
build (a persisted bundle whose CrossCutting/Staff/Metadata blocks were
|
||||
stamped major 0 under the old per-kind rule) have no in-build decoder — the
|
||||
frozen v1 layer covers whole-`Score` snapshots only, while binary_format's
|
||||
migration table ratifies "a v0/v1 op block migrates on read". This is
|
||||
ACCEPTABLE TODAY because (a) no production corpus exists (local repo, test
|
||||
bundles only), and (b) no code path decodes op-envelope bytes back to values
|
||||
(the bundle treats block bytes opaquely; reduction runs on in-memory
|
||||
envelopes; the opindex reads only the leading id). The moment a consumer
|
||||
byte-reconstructs op payloads (bundle replay of foreign documents, the P2
|
||||
ops-decoder fuzzer, MusicXML round-trip tooling), it MUST bring the
|
||||
op-payload migrate-on-read primitive with it — per-type frozen v1 payload
|
||||
decoders keyed by the block's stamped major. Tracked as the standing Phase-C
|
||||
remainder in the Push-2 plan.
|
||||
|
|
|
|||
|
|
@ -494,6 +494,8 @@ mod tests {
|
|||
offset: epiphany_core::AnchorOffset::Zero,
|
||||
},
|
||||
staves: vec![StaffId::new(ReplicaId(3), 0)],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
};
|
||||
let e = env(primitive(OperationKind::CreateCrossCutting(
|
||||
CreateCrossCuttingOp {
|
||||
|
|
|
|||
|
|
@ -80,9 +80,8 @@ impl OperationPayload {
|
|||
}
|
||||
|
||||
/// The binary-format schema major this payload's canonical encoding requires
|
||||
/// ([`OperationKind::schema_major`]). Only a primitive `CreateRegion` is
|
||||
/// major 1; the meta-operations embed no schema-major-1 value, so they are
|
||||
/// major 0.
|
||||
/// ([`OperationKind::schema_major`]). The meta-operations embed no
|
||||
/// data-model value, so they are major 0.
|
||||
pub fn schema_major(&self) -> u16 {
|
||||
match self {
|
||||
OperationPayload::Primitive(kind) => kind.schema_major(),
|
||||
|
|
@ -187,16 +186,46 @@ pub enum OperationKind {
|
|||
|
||||
impl OperationKind {
|
||||
/// The binary-format schema major this kind's canonical payload encodes at
|
||||
/// (Binary Format companion §"Schema Major 1"). `CreateRegion` embeds a
|
||||
/// [`Region`], which grew `permits_spanning_slurs` at schema major 1, so its
|
||||
/// payload is major 1; every other kind's payload is unchanged from major 0.
|
||||
/// (Binary Format companion §"Schema Major 1" / §"Schema Major 2").
|
||||
///
|
||||
/// An op-envelope block's schema major is the maximum over the operations it
|
||||
/// carries: a block bearing any `CreateRegion` is stamped major 1, and a
|
||||
/// major-0-only reader opens such a bundle read-only.
|
||||
/// An op-envelope block's schema major is the maximum over the operations
|
||||
/// it carries. **Minimal stamping** (Binary Format §Schema Major 2): the
|
||||
/// stamp is the *lowest* major whose layouts decode the payload's bytes —
|
||||
/// a pure function of the value, so identical content stamps (and hashes)
|
||||
/// identically. The kinds whose v2 fills are mandatory appended fields
|
||||
/// are always major 2; the kinds whose only v2 embedding hides behind an
|
||||
/// `Option` (encoding byte-identically to the prior major when `None`)
|
||||
/// are value-dependent.
|
||||
pub fn schema_major(&self) -> u16 {
|
||||
match self {
|
||||
OperationKind::CreateRegion(_) => 1,
|
||||
// Mandatory v2 appends: CrossCuttingValue (Slur/Tie/Beam/Spanner
|
||||
// bodies), Staff (default_clef + filled line config), and
|
||||
// ScoreMetadata (six appended fields).
|
||||
OperationKind::CreateCrossCutting(_)
|
||||
| OperationKind::ModifyCrossCutting(_)
|
||||
| OperationKind::CreateStaff(_)
|
||||
| OperationKind::SetMetadata(_) => 2,
|
||||
// Value-dependent: the embedded StaffLineConfiguration rides an
|
||||
// Option; None encodes byte-identically to the prior major.
|
||||
OperationKind::CreateRegion(op) => {
|
||||
if op
|
||||
.region
|
||||
.content
|
||||
.staff_instances()
|
||||
.iter()
|
||||
.any(|si| si.staff_lines_override.is_some())
|
||||
{
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
OperationKind::CreateStaffInstance(op)
|
||||
if op.instance.staff_lines_override.is_some() =>
|
||||
{
|
||||
2
|
||||
}
|
||||
OperationKind::SetStaffLayout(op) if op.staff_lines_override.is_some() => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7848,6 +7848,110 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_majors_follow_the_minimal_stamping_rule() {
|
||||
// Binary Format §Schema Major 2, "Minimal stamping": the stamp is the
|
||||
// lowest major whose layouts decode the payload's bytes — a pure
|
||||
// function of the value. Mandatory-append kinds are always 2; the
|
||||
// Option-hidden embeddings are value-dependent; everything else keeps
|
||||
// its prior major.
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CrossCuttingValue,
|
||||
SetStaffLayoutOp,
|
||||
};
|
||||
use epiphany_core::{RegionId, SlurId, StaffInstanceId};
|
||||
|
||||
let slur = crate::valuegen::slur(
|
||||
SlurId::new(ReplicaId(9), 1),
|
||||
EventId::new(ReplicaId(9), 1),
|
||||
EventId::new(ReplicaId(9), 2),
|
||||
);
|
||||
assert_eq!(
|
||||
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(slur),
|
||||
})
|
||||
.schema_major(),
|
||||
2,
|
||||
"mandatory v2 appends stamp 2"
|
||||
);
|
||||
|
||||
let region = crate::valuegen::region(RegionId::new(ReplicaId(9), 3));
|
||||
assert_eq!(
|
||||
OperationKind::CreateRegion(CreateRegionOp {
|
||||
region: region.clone()
|
||||
})
|
||||
.schema_major(),
|
||||
1,
|
||||
"an empty-instance CreateRegion keeps its v1 stamp"
|
||||
);
|
||||
|
||||
let iid = StaffInstanceId::new(ReplicaId(9), 4);
|
||||
let mut instance = crate::valuegen::staff_instance(iid, StaffId::new(ReplicaId(9), 5));
|
||||
assert_eq!(
|
||||
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
|
||||
region: region.id,
|
||||
instance: instance.clone(),
|
||||
})
|
||||
.schema_major(),
|
||||
0,
|
||||
"a None-override instance encodes byte-identically at v0"
|
||||
);
|
||||
instance.staff_lines_override = Some(epiphany_core::StaffLineConfiguration::default());
|
||||
assert_eq!(
|
||||
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
|
||||
region: region.id,
|
||||
instance,
|
||||
})
|
||||
.schema_major(),
|
||||
2,
|
||||
"a Some-override instance bears the v2 StaffLineConfiguration"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OperationKind::SetStaffLayout(SetStaffLayoutOp {
|
||||
staff_instance: iid,
|
||||
instrument_override: None,
|
||||
staff_lines_override: None,
|
||||
visible: true,
|
||||
})
|
||||
.schema_major(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
OperationKind::SetStaffLayout(SetStaffLayoutOp {
|
||||
staff_instance: iid,
|
||||
instrument_override: None,
|
||||
staff_lines_override: Some(epiphany_core::StaffLineConfiguration::default()),
|
||||
visible: true,
|
||||
})
|
||||
.schema_major(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canonical_base_is_byte_identical_across_data_model_majors() {
|
||||
// Binary Format §Schema Major 1 / §Schema Major 2: the canonical-base
|
||||
// MaterializedState embeds none of the data-model-major values, so its
|
||||
// bytes MUST NOT move across those bumps. This golden-locks a seeded
|
||||
// reduction's canonical bytes; if it fails after a data-model change,
|
||||
// a filled type has leaked into the canonical base — which the majors
|
||||
// promise not to do. (A deliberate change to the base's own vocabulary
|
||||
// — an appended discriminant the seeded corpus emits — re-pins this
|
||||
// consciously.)
|
||||
let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xBA5E);
|
||||
let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(envelopes);
|
||||
let bytes = set.reduce().canonical_bytes();
|
||||
let digest = epiphany_determinism::blake3_256(&bytes);
|
||||
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"65ad7ce56c6e8f37fbbbdab7dca8654507b3c952b0895673b453944623e42070"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_skips_system_derived_targets_p12_k3() {
|
||||
// Review finding on P12-K3: Transpose must not rewrite a
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ pub fn slur(id: SlurId, start: EventId, end: EventId) -> Slur {
|
|||
id,
|
||||
start_event: start,
|
||||
end_event: end,
|
||||
kind: Default::default(),
|
||||
curvature_override: None,
|
||||
style: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +171,7 @@ pub fn tie(id: TieId, start: EventId, end: EventId) -> Tie {
|
|||
end_event: end,
|
||||
pitch_pairing: None,
|
||||
class: TieClass::LaissezVibrer,
|
||||
style: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +181,8 @@ pub fn beam(id: BeamId, events: Vec<EventId>) -> Beam {
|
|||
id,
|
||||
events,
|
||||
level: 1,
|
||||
sub_beams: Vec::new(),
|
||||
geometry_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +284,12 @@ pub fn score_metadata(nth: u8) -> epiphany_core::ScoreMetadata {
|
|||
title: Some(format!("title-{nth}")),
|
||||
composer: Some("composer".to_string()),
|
||||
copyright: None,
|
||||
subtitle: None,
|
||||
lyricist: None,
|
||||
arranger: None,
|
||||
creation_timestamp: Default::default(),
|
||||
modification_timestamp: Default::default(),
|
||||
additional: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +312,7 @@ pub fn staff(id: StaffId, instrument: epiphany_core::InstrumentId) -> epiphany_c
|
|||
instrument,
|
||||
default_staff_lines: epiphany_core::StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: epiphany_core::Clef::treble(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -726,6 +726,8 @@ fn spanner_over(id: epiphany_core::SpannerId, a: EventId, b: EventId) -> epiphan
|
|||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
staves: Vec::new(),
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3124,7 +3126,12 @@ fn set_staff_layout_is_an_advisory_lww_with_tombstone_noop() {
|
|||
prim(OperationKind::SetStaffLayout(SetStaffLayoutOp {
|
||||
staff_instance: instance,
|
||||
instrument_override: None,
|
||||
staff_lines_override: Some(epiphany_core::StaffLineConfiguration { line_count: 1 }),
|
||||
staff_lines_override: Some(epiphany_core::StaffLineConfiguration {
|
||||
line_count: 1,
|
||||
line_spacing: epiphany_core::SpaceUnit::normal(),
|
||||
line_style: Default::default(),
|
||||
bracket: None,
|
||||
}),
|
||||
visible: false,
|
||||
})),
|
||||
);
|
||||
|
|
@ -3148,7 +3155,12 @@ fn set_staff_layout_is_an_advisory_lww_with_tombstone_noop() {
|
|||
assert_eq!(materialized.instrument_override, None);
|
||||
assert_eq!(
|
||||
materialized.staff_lines_override,
|
||||
Some(epiphany_core::StaffLineConfiguration { line_count: 1 })
|
||||
Some(epiphany_core::StaffLineConfiguration {
|
||||
line_count: 1,
|
||||
line_spacing: epiphany_core::SpaceUnit::normal(),
|
||||
line_style: Default::default(),
|
||||
bracket: None,
|
||||
})
|
||||
);
|
||||
assert!(!materialized.visible);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@ use epiphany_core::{
|
|||
check_invariants, derive_annotations, AleatoricAnchoringDiscipline, AleatoricTimeModel, Canvas,
|
||||
CmnNominal, CueEvent, CueRendering, DerivedAnnotations, Event, EventArena, EventDuration,
|
||||
EventPosition, GraceKind, GraphicEvent, IdentifiedPitch, IdentityContext, IndeterminacyHints,
|
||||
IndeterminacyKind, IndeterminateEvent, Instrument, MetricTimeModel, MusicalDuration,
|
||||
MusicalPosition, PitchSpelling, PowerOfTwo, PrePassProfile, ProportionalTimeModel, Region,
|
||||
RegionContent, RegionTimeModel, Score, SpellingAttachment, SpellingDirective, SpellingNominal,
|
||||
SpellingScope, SpellingSource, Staff, StaffBasedContent, StaffExtent, StaffInstance,
|
||||
StaffLineConfiguration, StaffPosition, StemConfiguration, TaxonomyReport, TimeAnchor,
|
||||
TimeExtent, TimeSignature, TimeSignatureDisplay, TrajectoryDisplay, TrajectoryEndpoint,
|
||||
TrajectoryEvent, TrajectoryShape, Tuplet, TupletRatio, UnpitchedEvent, UnpitchedMemberId,
|
||||
Voice, WallClockDuration, WallClockTime,
|
||||
IndeterminacyKind, IndeterminateEvent, MetricTimeModel, MusicalDuration, MusicalPosition,
|
||||
PitchSpelling, PowerOfTwo, PrePassProfile, ProportionalTimeModel, Region, RegionContent,
|
||||
RegionTimeModel, Score, SpellingAttachment, SpellingDirective, SpellingNominal, SpellingScope,
|
||||
SpellingSource, Staff, StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration,
|
||||
StaffPosition, StemConfiguration, TaxonomyReport, TimeAnchor, TimeExtent, TimeSignature,
|
||||
TimeSignatureDisplay, TrajectoryDisplay, TrajectoryEndpoint, TrajectoryEvent, TrajectoryShape,
|
||||
Tuplet, TupletRatio, UnpitchedEvent, UnpitchedMemberId, Voice, WallClockDuration,
|
||||
WallClockTime,
|
||||
};
|
||||
use epiphany_core::{
|
||||
AccidentalId, AcousticPitch, AcousticRealization, BeatGroup, EventId, InstrumentId, Pitch,
|
||||
|
|
@ -507,12 +507,12 @@ impl OneStaff {
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: epiphany_core::Clef::treble(),
|
||||
}];
|
||||
score.instruments = vec![Instrument {
|
||||
id: instrument,
|
||||
name: String::from("F-corpus"),
|
||||
range: None,
|
||||
}];
|
||||
score.instruments = vec![epiphany_core::Instrument::new(
|
||||
instrument,
|
||||
String::from("F-corpus"),
|
||||
)];
|
||||
score.events = arena;
|
||||
score.cross_cutting = cross_cutting;
|
||||
score.spelling_attachments = spelling_attachments;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
use epiphany_core::{
|
||||
AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal,
|
||||
CrossCuttingRegistry, Event, EventArena, EventDuration, EventPosition, IdentifiedPitch,
|
||||
IdentityContext, Instrument, Marker, Measure, MetricTimeModel, MusicalDuration,
|
||||
MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent,
|
||||
RegionEdge, RegionTimeModel, ScalePosition, Score, Spanner, Staff, StaffBasedContent,
|
||||
StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, TieClass,
|
||||
TimeAnchor, TimeExtent, TuningReference, Voice, WallClockTime,
|
||||
IdentityContext, Marker, Measure, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch,
|
||||
PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, RegionEdge, RegionTimeModel,
|
||||
ScalePosition, Score, Spanner, Staff, StaffBasedContent, StaffExtent, StaffInstance,
|
||||
StaffLineConfiguration, StemConfiguration, Tie, TieClass, TimeAnchor, TimeExtent,
|
||||
TuningReference, Voice, WallClockTime,
|
||||
};
|
||||
use epiphany_core::{
|
||||
ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId, ReplicaId,
|
||||
|
|
@ -115,6 +115,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score {
|
|||
end_event: events[1],
|
||||
pitch_pairing: None,
|
||||
class: TieClass::Standard,
|
||||
style: Default::default(),
|
||||
});
|
||||
cross_cutting.spanners.push(Spanner {
|
||||
id: idc.mint::<SpannerId>(),
|
||||
|
|
@ -127,6 +128,8 @@ pub fn ten_measure_single_staff(seed: u64) -> Score {
|
|||
time: WallClockTime(10),
|
||||
},
|
||||
staves: vec![staff_id],
|
||||
kind: Default::default(),
|
||||
style: Default::default(),
|
||||
});
|
||||
cross_cutting.markers.push(Marker {
|
||||
id: idc.mint::<MarkerId>(),
|
||||
|
|
@ -171,11 +174,10 @@ pub fn ten_measure_single_staff(seed: u64) -> Score {
|
|||
|
||||
let mut score = Score::empty(idc.clone());
|
||||
score.identity = idc;
|
||||
score.instruments = vec![Instrument {
|
||||
id: instrument,
|
||||
name: String::from("Flute"),
|
||||
range: None,
|
||||
}];
|
||||
score.instruments = vec![epiphany_core::Instrument::new(
|
||||
instrument,
|
||||
String::from("Flute"),
|
||||
)];
|
||||
score.staves = vec![Staff {
|
||||
id: staff_id,
|
||||
name: String::from("Flute"),
|
||||
|
|
@ -183,6 +185,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score {
|
|||
instrument,
|
||||
default_staff_lines: StaffLineConfiguration::default(),
|
||||
group: None,
|
||||
default_clef: epiphany_core::Clef::treble(),
|
||||
}];
|
||||
score.events = arena;
|
||||
score.cross_cutting = cross_cutting;
|
||||
|
|
|
|||
|
|
@ -626,15 +626,62 @@ mod tests {
|
|||
assert_eq!(blocks, vec![env.to_canonical_bytes()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_cutting_op_block_is_stamped_major_2_and_reopens_read_write() {
|
||||
// Schema major 2 (minimal stamping): a CreateCrossCutting payload's
|
||||
// v2 fills are mandatory appended fields, so the kind is always
|
||||
// major 2; the writer derives the block stamp from its operations.
|
||||
use epiphany_core::{OperationId, ReplicaId, SlurId, WallClockTime};
|
||||
use epiphany_ops::{
|
||||
AuthorId, CausalContext, CreateCrossCuttingOp, CrossCuttingValue, HybridLogicalClock,
|
||||
OperationKind, OperationPayload, OperationStamp,
|
||||
};
|
||||
let slur = epiphany_ops::valuegen::slur(
|
||||
SlurId::new(ReplicaId(9), 5),
|
||||
epiphany_core::EventId::new(ReplicaId(9), 100),
|
||||
epiphany_core::EventId::new(ReplicaId(9), 101),
|
||||
);
|
||||
let id = OperationId::new(ReplicaId(9), 2);
|
||||
let env = OperationEnvelope {
|
||||
id,
|
||||
author: AuthorId(0),
|
||||
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(2), 0), id),
|
||||
causal_context: CausalContext::new(),
|
||||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::CreateCrossCutting(
|
||||
CreateCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(slur),
|
||||
},
|
||||
)),
|
||||
};
|
||||
assert_eq!(
|
||||
env.schema_major(),
|
||||
2,
|
||||
"CreateCrossCutting encodes at schema major 2"
|
||||
);
|
||||
let block = crate::bundle_harness::stage_operation_block(std::slice::from_ref(&env));
|
||||
let reopened = reopen_with_op_block(0xD2_0003, block);
|
||||
// Major 2 is within the op-block accept-set [0,2]: read-write.
|
||||
assert_eq!(
|
||||
reopened.manifest().operation_roots[0].schema_version,
|
||||
SchemaVersion::V2
|
||||
);
|
||||
assert!(!reopened.is_read_only());
|
||||
let blocks = reopened
|
||||
.read_operation_block(&reopened.manifest().operation_roots[0])
|
||||
.expect("major-2 op block is admitted by the accept-set");
|
||||
assert_eq!(blocks, vec![env.to_canonical_bytes()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_block_beyond_the_accept_set_opens_read_only() {
|
||||
use epiphany_bundle::IntegrityAnomaly;
|
||||
// A newer writer's op block, stamped schema major 2 — beyond the reader's
|
||||
// op-block accept-set [0,1]. The bundle opens read-only preservation (the
|
||||
// A newer writer's op block, stamped schema major 3 — beyond the reader's
|
||||
// op-block accept-set [0,2]. The bundle opens read-only preservation (the
|
||||
// canonical base and manifest still read) rather than hard-rejecting.
|
||||
let block = StagedChunk::operation_block_versioned(
|
||||
encode_block(&[vec![1u8, 2, 3, 4]]),
|
||||
SchemaVersion::new(2, 0),
|
||||
SchemaVersion::new(3, 0),
|
||||
);
|
||||
let reopened = reopen_with_op_block(0xD2_0002, block);
|
||||
assert!(
|
||||
|
|
@ -643,7 +690,7 @@ mod tests {
|
|||
);
|
||||
assert!(reopened.anomalies().iter().any(|a| matches!(
|
||||
a,
|
||||
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 2 }
|
||||
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 3 }
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue