Agent I-0: real clef + key-signature data in the core graph

Prerequisite for the visible-slice engraving milestones (I-1+): give the
score graph the clef/key data the engrave pipeline needs for clef-relative
staff positioning and key-signature rendering, replacing the Chapter-7
TimeAnchor-only placeholders.

epiphany-core:
- ClefShape { G, F, C, Percussion } — each family's reference pitch (G4 / F3 /
  middle C4) pins the staff-position mapping.
- Clef { shape, line, octave_shift } — the SMuFL family, the staff line its
  reference pitch sits on (1 = bottom line), and an octave transposition; with
  treble/bass/alto/tenor constructors and a treble Default. Generalizes to any
  C-clef line and octave-transposing clefs.
- KeySignature — circle-of-fifths position, validated -7..=7: a private field
  behind KeySignature::new (Option) + fifths(), and a custom Codec that rejects
  an out-of-range count on decode (Reconstruct, as PowerOfTwo / Tempo do).
- ClefChange now carries clef: Clef; KeySignatureChange carries key: KeySignature.
- Codec (cstyle_enum_codec / struct_codec / the validating KeySignature impl),
  lib.rs re-exports, a round-trip test over every shape x line x octave and every
  fifths value, and an out-of-range decode-rejection regression test.

Zero blast radius: nothing constructed these with data (clef/key sequences are
always empty) and invariants only read .anchor, so existing Score bytes, hashes,
and goldens are unchanged. Fixtures get populated and the engrave pipeline reads
the data in I-1.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green;
conformance_suite scale 1 passes. Stages only epiphany-core; the pre-existing
Agent-I working tree (engrave/layout-ir/render-svg + .gitignore) stays unstaged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-26 10:31:16 -04:00
parent dfdbc625b1
commit 47a581a4de
3 changed files with 201 additions and 16 deletions

View File

@ -48,15 +48,16 @@ use crate::event::{
use crate::graph::{
AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation,
AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, BeatGroup, Canvas,
ChordSymbol, ClefChange, Comment, CrossCuttingRegistry, DecompositionAttachment,
DecompositionSource, EventOrderingDAG, GestureAnchoring, GraphicContent, GraphicGesture,
GraphicObject, Instrument, 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,
ChordSymbol, Clef, ClefChange, ClefShape, Comment, 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,
};
use crate::ids::{
AnalysisLayerId, AnalyticalAnnotationId, BarlineAlignmentGroupId, BeamId, ChordSymbolId,
@ -1453,8 +1454,27 @@ struct_codec!(MeterChange {
struct_codec!(MetricGrid { meter_sequence });
struct_codec!(ProportionalTimeModel { duration });
struct_codec!(MetricTimeModel { meters, tempo });
struct_codec!(ClefChange { anchor });
struct_codec!(KeySignatureChange { anchor });
cstyle_enum_codec!(ClefShape {
0 => G,
1 => F,
2 => C,
3 => Percussion,
});
struct_codec!(Clef {
shape,
line,
octave_shift
});
impl Codec for KeySignature {
fn enc(&self, out: &mut Vec<u8>) {
self.fifths().enc(out);
}
fn dec(r: &mut Reader<'_>) -> Result<Self> {
KeySignature::new(i8::dec(r)?).ok_or(ScoreDecodeError::Reconstruct("KeySignature"))
}
}
struct_codec!(ClefChange { anchor, clef });
struct_codec!(KeySignatureChange { anchor, key });
struct_codec!(Measure {
id,
start,
@ -2193,6 +2213,72 @@ mod tests {
);
}
#[test]
fn clef_and_key_signature_codecs_round_trip() {
use crate::graph::{Clef, ClefChange, ClefShape, KeySignature, KeySignatureChange};
use crate::time::{TimeAnchor, WallClockTime};
fn rt<T: Codec + PartialEq + core::fmt::Debug>(v: &T) {
let mut out = Vec::new();
v.enc(&mut out);
let mut r = Reader::new(&out);
let decoded = T::dec(&mut r).expect("decodes");
assert_eq!(&decoded, v, "round-trip changed the value");
}
for shape in [
ClefShape::G,
ClefShape::F,
ClefShape::C,
ClefShape::Percussion,
] {
rt(&shape);
for line in [-3i8, 1, 2, 3, 4, 5] {
for octave_shift in [-1i8, 0, 1] {
rt(&Clef {
shape,
line,
octave_shift,
});
}
}
}
for shape_clef in [Clef::treble(), Clef::bass(), Clef::alto(), Clef::tenor()] {
rt(&shape_clef);
}
for fifths in -7i8..=7 {
rt(&KeySignature::new(fifths).expect("fifths is in range"));
}
let anchor = TimeAnchor::WallClock {
time: WallClockTime(0),
};
rt(&ClefChange {
anchor: anchor.clone(),
clef: Clef::bass(),
});
rt(&KeySignatureChange {
anchor,
key: KeySignature::new(-3).expect("fifths is in range"),
});
}
#[test]
fn key_signature_rejects_out_of_range_fifths_on_decode() {
let decode = |fifths: i8| {
let mut bytes = Vec::new();
fifths.enc(&mut bytes);
KeySignature::dec(&mut Reader::new(&bytes))
};
assert!(decode(-7).is_ok());
assert!(decode(7).is_ok());
for fifths in [-8i8, 8] {
assert!(
matches!(decode(fifths), Err(ScoreDecodeError::Reconstruct(_))),
"out-of-range fifth count {fifths} must be rejected"
);
}
}
#[test]
fn generator_scores_round_trip() {
for seed in 0..200u64 {

View File

@ -51,17 +51,116 @@ impl Default for StaffLineConfiguration {
}
}
/// A clef placed at a point in a staff instance. Placeholder (Chapter 7).
/// 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)]
pub enum ClefShape {
/// G clef (treble family) — reference pitch G4.
G,
/// F clef (bass family) — reference pitch F3.
F,
/// C clef (alto / tenor family) — reference pitch middle C (C4).
C,
/// Unpitched percussion clef — no diatonic reference.
Percussion,
}
/// A clef: the SMuFL [`ClefShape`], the staff line its reference pitch sits on
/// (`1` = the bottom line of the staff, counting up), and an octave
/// transposition (`-1` for treble-8vb, `+1` for treble-8va, …). The shape's
/// reference pitch on `line` fixes where every pitch under this clef lands on
/// the staff.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct Clef {
pub shape: ClefShape,
pub line: i8,
pub octave_shift: i8,
}
impl Clef {
/// Treble clef — G clef on line 2.
pub const fn treble() -> Self {
Clef {
shape: ClefShape::G,
line: 2,
octave_shift: 0,
}
}
/// Bass clef — F clef on line 4.
pub const fn bass() -> Self {
Clef {
shape: ClefShape::F,
line: 4,
octave_shift: 0,
}
}
/// Alto clef — C clef on line 3.
pub const fn alto() -> Self {
Clef {
shape: ClefShape::C,
line: 3,
octave_shift: 0,
}
}
/// Tenor clef — C clef on line 4.
pub const fn tenor() -> Self {
Clef {
shape: ClefShape::C,
line: 4,
octave_shift: 0,
}
}
}
impl Default for Clef {
fn default() -> Self {
Clef::treble()
}
}
/// A key signature as a position on the circle of fifths: `fifths` sharps when
/// positive (`1` = G major, one sharp), flats when negative (`-1` = F major,
/// one flat), and `0` for C major / A minor. The accidental set the renderer
/// draws is derived from this count.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct KeySignature {
fifths: i8,
}
impl KeySignature {
/// Lowest key-signature fifth count in conventional CMN notation: seven flats.
pub const MIN_FIFTHS: i8 = -7;
/// Highest key-signature fifth count in conventional CMN notation: seven sharps.
pub const MAX_FIFTHS: i8 = 7;
/// Builds a key signature, rejecting values outside the conventional
/// `-7..=7` circle-of-fifths range.
pub const fn new(fifths: i8) -> Option<Self> {
if fifths >= Self::MIN_FIFTHS && fifths <= Self::MAX_FIFTHS {
Some(KeySignature { fifths })
} else {
None
}
}
/// The circle-of-fifths position (`-7..=7`).
pub const fn fifths(self) -> i8 {
self.fifths
}
}
/// A clef placed at a point in a staff instance (Chapter 7).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ClefChange {
pub anchor: TimeAnchor,
pub clef: Clef,
}
/// A key-signature change at a point in a staff instance. Placeholder
/// (Chapter 7).
/// A key-signature change at a point in a staff instance (Chapter 7).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct KeySignatureChange {
pub anchor: TimeAnchor,
pub key: KeySignature,
}
/// Whether and how a measure number is shown. Placeholder (Chapter 7).

View File

@ -99,9 +99,9 @@ pub use event::{
pub use graph::{
derive_promoted_voice_id, AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer,
AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam,
BeatGroup, Canvas, ChordSymbol, ClefChange, Comment, CoordinateDiscipline,
BeatGroup, Canvas, ChordSymbol, Clef, ClefChange, ClefShape, Comment, CoordinateDiscipline,
CrossCuttingRegistry, DecompositionAttachment, DecompositionSource, EventOrderingDAG,
GestureAnchoring, GraphicContent, GraphicGesture, GraphicObject, Instrument,
GestureAnchoring, GraphicContent, GraphicGesture, GraphicObject, Instrument, KeySignature,
KeySignatureChange, LyricLine, Marker, Measure, MeasureNumberVisibility, MeterChange,
MetricGrid, MetricTimeModel, NotatedComponent, NoteValue, PartDefinition, PowerOfTwo,
ProportionalTimeModel, Region, RegionContent, RegionTimeModel, RepeatStructure, Score,