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:
Levi Neuwirth 2026-07-07 16:30:07 -04:00
parent 7c9d1c40aa
commit 794400c4c7
22 changed files with 1975 additions and 200 deletions

View File

@ -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.

View File

@ -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(

View File

@ -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 },
}
}

View File

@ -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

View File

@ -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"
);
}
}
}

View File

@ -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 => {

View File

@ -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&nbsp;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").

View File

@ -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(),

View File

@ -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::{

View File

@ -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],

View File

@ -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 {

View File

@ -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);

View File

@ -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.

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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

View File

@ -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(),
}
}

View File

@ -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());

View File

@ -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;

View File

@ -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;

View File

@ -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 }
)));
}