epiphany/crates/epiphany-core/src/generators.rs

1183 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Deterministic generators and shrinkers for the graph invariants
//! (QUICKSTART, Agent B: *"For each invariant, you must have a positive
//! generator that produces valid graphs and a negative shrinker that minimizes
//! invariant violations to a small witness for debugging"*).
//!
//! * [`valid_score`] is the **positive generator**: a seeded, well-formed score
//! graph for which [`check_invariants`](crate::check_invariants) is empty.
//! [`arbitrary_graph_corpus`] is the seeded corpus the property tests sweep.
//! * [`violating_score`] is the **negative generator**: for any
//! [`GraphInvariant`] it produces a graph that violates that invariant.
//! * [`shrink`] is the **shrinker**: it greedily prunes a violating graph to a
//! small witness that still violates the same invariant, retaining only the
//! structure the violation needs.
//!
//! Generation is deterministic (a vendored SplitMix64, like Agent A's fuzz
//! harness) so a failing case reproduces exactly from its seed; no platform
//! entropy enters generation (Appendix D §"Randomness").
use epiphany_determinism::fuzz::SplitMix64;
use crate::event::{Event, PitchedEvent, StemConfiguration};
use crate::graph::{
AleatoricAnchoringDiscipline, AleatoricTimeModel, Canvas, ChordSymbol, DecompositionAttachment,
DecompositionSource, Instrument, Marker, Measure, MetricTimeModel, NotatedComponent, NoteValue,
ProportionalTimeModel, Region, RegionContent, RegionTimeModel, Score, Slur, Spanner, Staff,
StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration, Tie, TieClass,
TimeExtent, Tuplet, TupletRatio, Voice,
};
use crate::ids::{
ChordSymbolId, EventId, IdentityContext, InstrumentId, MarkerId, MeasureId, PitchId, RegionId,
ReplicaId, SlurId, SpannerId, StaffId, StaffInstanceId, TieId, TupletId, VoiceId,
};
use crate::invariants::{check_invariant, GraphInvariant};
use crate::pitch::{
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
PitchSpacePosition, PitchSpelling, ScalePosition, SpellingAttachment, SpellingDirective,
SpellingScope, SpellingSource, TuningReference,
};
use crate::time::{
AnchorOffset, EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
RegionEdge, TimeAnchor, WallClockDuration, WallClockTime,
};
const NOMINALS: [CmnNominal; 7] = [
CmnNominal::C,
CmnNominal::D,
CmnNominal::E,
CmnNominal::F,
CmnNominal::G,
CmnNominal::A,
CmnNominal::B,
];
fn cmn_identified_pitch(pid: PitchId, step: usize) -> IdentifiedPitch {
IdentifiedPitch {
id: pid,
pitch: Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Cmn {
nominal: NOMINALS[step % 7],
alteration: 0,
octave: 4,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
},
}
}
/// A single-note pitched event at musical position `index/4`, duration `1/4`.
fn pitched_event(eid: EventId, voice: VoiceId, pid: PitchId, index: i64) -> Event {
Event::Pitched(PitchedEvent {
id: eid,
voice,
position: EventPosition::Musical(MusicalPosition(RationalTime::new(index, 4).unwrap())),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: vec![cmn_identified_pitch(pid, index as usize)],
articulations: Vec::new(),
dynamic: None,
ornaments: Vec::new(),
stem: StemConfiguration,
grace: None,
})
}
/// The positive generator: a seeded, well-formed metric score graph.
///
/// Shape: one metric region placed on a wall-clock extent, 12 staves, each
/// manifested by one staff instance carrying 12 voices of 24 single-note
/// pitched events at non-overlapping quarter-note positions. Every Chapter 5
/// invariant holds.
pub fn valid_score(seed: u64) -> Score {
let mut rng = SplitMix64::new(seed ^ 0x5EED_C0DE_1234_5678);
// A non-reserved replica (re-draw on the astronomically unlikely reserved
// value).
let replica =
ReplicaId::from_entropy(rng.next_u64().to_le_bytes()).unwrap_or(ReplicaId(0x1234));
let mut idc = IdentityContext::new(replica);
let staff_count = 1 + (rng.next_u64() % 2) as usize; // 1..=2
let region_id: RegionId = idc.mint();
let mut staves = Vec::new();
let mut instruments = Vec::new();
let mut instances = Vec::new();
let mut arena = crate::event::EventArena::new();
let mut staff_extent = Vec::new();
for _ in 0..staff_count {
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::new(instrument, String::from("instrument")));
staves.push(Staff {
id: staff_id,
name: String::from("staff"),
abbreviation: None,
instrument,
default_staff_lines: StaffLineConfiguration::default(),
group: None,
default_clef: crate::graph::Clef::treble(),
});
staff_extent.push(staff_id);
let instance_id: StaffInstanceId = idc.mint();
let mut instance = StaffInstance::new(instance_id, staff_id);
let voice_count = 1 + (rng.next_u64() % 2) as usize; // 1..=2
for _ in 0..voice_count {
let voice_id: VoiceId = idc.mint();
let mut voice = Voice::user(voice_id);
let event_count = 2 + (rng.next_u64() % 3) as i64; // 2..=4
for index in 0..event_count {
let eid: EventId = idc.mint();
let pid: PitchId = idc.mint();
arena
.insert(pitched_event(eid, voice_id, pid, index))
.expect("fresh event id");
voice.events.push(eid);
}
instance.voices.push(voice);
}
instances.push(instance);
}
let region = Region {
id: region_id,
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: instances,
..Default::default()
}),
time_extent: TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(1_000_000),
},
},
staff_extent: StaffExtent {
staves: staff_extent,
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
let mut score = Score::empty(idc.clone());
score.identity = idc;
score.staves = staves;
score.instruments = instruments;
score.events = arena;
score.canvas = Canvas {
regions: vec![region],
..Default::default()
};
score
}
/// `count` valid graphs from successive seeds — the "arbitrary-graph corpus"
/// the hand-off gate runs clean against.
pub fn arbitrary_graph_corpus(count: u64, base_seed: u64) -> impl Iterator<Item = Score> {
(0..count).map(move |i| valid_score(base_seed.wrapping_add(i).wrapping_mul(0x9E37_79B9)))
}
/// A second positive generator exercising the breadth the simple
/// [`valid_score`] does not: three **concurrent** regions on disjoint staves —
/// metric (with measures, an eighth-note triplet [`Tuplet`], a standard [`Tie`],
/// a [`Spanner`], a [`Marker`], a [`ChordSymbol`], and a
/// [`DecompositionAttachment`]), proportional (wall-clock events), and aleatoric
/// (musical-discipline events) — plus tombstoned pitch/event ids and a spelling
/// attachment that resolves to the tombstoned pitch. Every Chapter 5 invariant
/// holds; this is the corpus that exercises the reference, anchor, tuplet, tie,
/// decomposition, and tombstone paths.
pub fn valid_score_rich(seed: u64) -> Score {
let mut rng = SplitMix64::new(seed ^ 0x1CE_B00D_A11C_E5EE);
let replica =
ReplicaId::from_entropy(rng.next_u64().to_le_bytes()).unwrap_or(ReplicaId(0x7777));
let mut idc = IdentityContext::new(replica);
let extent = || TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(1_000_000),
},
};
let mut staves = Vec::new();
let mut instruments = Vec::new();
let mk_staff = |idc: &mut IdentityContext,
staves: &mut Vec<Staff>,
instruments: &mut Vec<Instrument>|
-> StaffId {
let id: StaffId = idc.mint();
let instrument: InstrumentId = idc.mint();
instruments.push(Instrument::new(instrument, String::from("instrument")));
staves.push(Staff {
id,
name: String::from("staff"),
abbreviation: None,
instrument,
default_staff_lines: StaffLineConfiguration::default(),
group: None,
default_clef: crate::graph::Clef::treble(),
});
id
};
let staff_a = mk_staff(&mut idc, &mut staves, &mut instruments);
let staff_b = mk_staff(&mut idc, &mut staves, &mut instruments);
let staff_c = mk_staff(&mut idc, &mut staves, &mut instruments);
let mut arena = crate::event::EventArena::new();
let mut cross_cutting = crate::graph::CrossCuttingRegistry::default();
let mut decomposition_attachments = Vec::new();
// --- Metric region on staff A: triplet of three C4 eighths (each 1/12). --
let region_a: RegionId = idc.mint();
let inst_a: StaffInstanceId = idc.mint();
let voice_a: VoiceId = idc.mint();
let mut va = Voice::user(voice_a);
let mut triplet_members = Vec::new();
for k in 0..3i64 {
let eid: EventId = idc.mint();
let pid: PitchId = idc.mint();
let ev = Event::Pitched(PitchedEvent {
id: eid,
voice: voice_a,
position: EventPosition::Musical(MusicalPosition(RationalTime::new(k, 12).unwrap())),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 12).unwrap())),
// All C4 so any adjacent pair is enharmonically equivalent (valid tie).
pitches: vec![cmn_identified_pitch(pid, 0)],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
});
arena.insert(ev).unwrap();
va.events.push(eid);
triplet_members.push(eid);
}
let measure_a: MeasureId = idc.mint();
let mut sia = StaffInstance::new(inst_a, staff_a);
sia.voices.push(va);
sia.measures.push(Measure {
id: measure_a,
// Region-anchored with a musical offset: valid offset kind for a metric
// region (invariant 9), target resolves (invariant 10).
start: TimeAnchor::Region {
id: region_a,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: None,
explicit_number: Some(1),
number_visibility: Default::default(),
});
// Triplet, tie, decomposition, spanner, marker, chord symbol.
let triplet_id: TupletId = idc.mint();
cross_cutting.tuplets.push(Tuplet {
id: triplet_id,
ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"),
members: triplet_members.clone(),
parent: None,
required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()),
});
cross_cutting.ties.push(Tie {
id: idc.mint::<TieId>(),
start_event: triplet_members[0],
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).
decomposition_attachments.push(DecompositionAttachment {
target: triplet_members[0],
components: vec![NotatedComponent {
base_value: NoteValue::Eighth,
dots: 0,
tuplet: Some(triplet_id),
tied_to_next: false,
}],
source: DecompositionSource::Inferred,
});
cross_cutting.spanners.push(Spanner {
id: idc.mint::<SpannerId>(),
start: TimeAnchor::Region {
id: region_a,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
end: TimeAnchor::WallClock {
time: WallClockTime(10),
},
staves: vec![staff_a],
kind: Default::default(),
style: Default::default(),
});
cross_cutting.markers.push(Marker {
id: idc.mint::<MarkerId>(),
anchor: TimeAnchor::Region {
id: region_a,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
});
cross_cutting.chord_symbols.push(ChordSymbol {
id: idc.mint::<ChordSymbolId>(),
anchor: TimeAnchor::Region {
id: region_a,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
});
let region_a = Region {
id: region_a,
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![sia],
..Default::default()
}),
time_extent: extent(),
staff_extent: StaffExtent {
staves: vec![staff_a],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
// --- Proportional region on staff B: wall-clock events. ------------------
let inst_b: StaffInstanceId = idc.mint();
let voice_b: VoiceId = idc.mint();
let mut vb = Voice::user(voice_b);
for k in 0..2i64 {
let eid: EventId = idc.mint();
let pid: PitchId = idc.mint();
arena
.insert(Event::Pitched(PitchedEvent {
id: eid,
voice: voice_b,
position: EventPosition::WallClock(WallClockTime(k * 1000)),
duration: EventDuration::WallClock(WallClockDuration(1000)),
pitches: vec![cmn_identified_pitch(pid, k as usize)],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
}))
.unwrap();
vb.events.push(eid);
}
let mut sib = StaffInstance::new(inst_b, staff_b);
sib.voices.push(vb);
let region_b = Region {
id: idc.mint(),
time_model: RegionTimeModel::Proportional(ProportionalTimeModel {
duration: WallClockDuration(1_000_000),
}),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![sib],
..Default::default()
}),
time_extent: extent(),
staff_extent: StaffExtent {
staves: vec![staff_b],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
// --- Aleatoric region on staff C (musical discipline). -------------------
let inst_c: StaffInstanceId = idc.mint();
let voice_c: VoiceId = idc.mint();
let mut vc = Voice::user(voice_c);
for k in 0..2i64 {
let eid: EventId = idc.mint();
let pid: PitchId = idc.mint();
arena
.insert(Event::Pitched(PitchedEvent {
id: eid,
voice: voice_c,
position: EventPosition::Musical(MusicalPosition(RationalTime::new(k, 4).unwrap())),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: vec![cmn_identified_pitch(pid, k as usize)],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
}))
.unwrap();
vc.events.push(eid);
}
let mut sic = StaffInstance::new(inst_c, staff_c);
sic.voices.push(vc);
let region_c = Region {
id: idc.mint(),
time_model: RegionTimeModel::Aleatoric(AleatoricTimeModel {
ordering: Default::default(),
anchoring: AleatoricAnchoringDiscipline::Musical,
bounds: Default::default(),
duration_hint: WallClockDuration(1_000_000),
}),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![sic],
..Default::default()
}),
time_extent: extent(),
staff_extent: StaffExtent {
staves: vec![staff_c],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
// --- Tombstones + a spelling attachment resolving to a tombstoned pitch. -
let tomb_pitch: PitchId = idc.mint();
let tomb_event: EventId = idc.mint();
let spelling_attachments = vec![SpellingAttachment {
scope: SpellingScope::Pitch(tomb_pitch),
directive: SpellingDirective::Explicit(PitchSpelling::cmn(CmnNominal::C, 4)),
source: SpellingSource::UserChosen,
priority: 0,
layer: None,
}];
let mut score = Score::empty(idc);
score.staves = staves;
score.instruments = instruments;
score.events = arena;
score.cross_cutting = cross_cutting;
score.decomposition_attachments = decomposition_attachments;
score.spelling_attachments = spelling_attachments;
score.tombstoned_pitches.insert(tomb_pitch);
score.tombstoned_events.insert(tomb_event);
score.canvas = Canvas {
regions: vec![region_a, region_b, region_c],
..Default::default()
};
score
}
// --- Small read accessors into a generated score. ---------------------------
fn first_region_id(s: &Score) -> RegionId {
s.canvas.regions[0].id
}
fn first_two_event_ids(s: &Score) -> (EventId, EventId) {
let evs = &s.canvas.regions[0].staff_instances()[0].voices[0].events;
(evs[0], evs[1])
}
fn first_pitch_id(s: &Score, event: EventId) -> PitchId {
let mut buf = Vec::new();
s.events
.get(event)
.unwrap()
.collect_identified_pitches(&mut buf);
buf[0].id
}
fn first_voice_mut(s: &mut Score) -> &mut Voice {
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0]
.voices
.get_mut(0)
.unwrap()
}
/// The negative generator: a graph that violates `inv`. Built from
/// [`valid_score`] then corrupted in the smallest way that triggers the
/// targeted invariant.
pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score {
use GraphInvariant::*;
let mut s = valid_score(seed);
let replica = s.identity.replica_id;
match inv {
StaffGroupMembershipAgreement => {
// P13-S16 touch row 8. Direction is PINNED to S->G: mint a group with
// an EMPTY `members` and point staff 0 at it. The staff names the
// group and the group omits the staff — the exact shape a pin-2
// maintenance gap produces, and the smallest corruption of
// `valid_score` (one field set, one empty group added).
//
// This must NOT violate G->S: `members` is empty, so that direction
// has nothing to disagree about. Both directions are the same
// `GraphInvariant`, so no `all()`-driven test can tell them apart —
// `invariant_21_negative_generator_breaks_staff_to_group_only` is the
// only guard on the direction, and it checks the shrunk fixture too.
let group_id = crate::ids::StaffGroupId::new(replica, 21_000);
s.staff_groups.push(crate::graph::StaffGroup {
id: group_id,
name: None,
kind: crate::graph::StaffGroupKind::Bracket,
members: Vec::new(),
});
s.staves[0].group = Some(group_id);
}
EventVoiceBacklink => {
// Arena event whose voice no longer lists it: drop it from the list.
let (e0, _) = first_two_event_ids(&s);
first_voice_mut(&mut s).events.retain(|e| *e != e0);
}
VoiceEventBacklink => {
// A voice lists an event that is not in the arena.
let ghost = EventId::new(replica, 9_999_999);
first_voice_mut(&mut s).events.push(ghost);
}
VoiceEventsSortedNonOverlap => {
// Stretch event 0 so it overlaps event 1.
let (e0, _) = first_two_event_ids(&s);
if let Some(Event::Pitched(p)) = s.events.get_mut(e0) {
p.duration =
EventDuration::Musical(MusicalDuration(RationalTime::new(1, 1).unwrap()));
}
}
EventCoordinateModel => {
// A wall-clock event inside a metric region.
let (e0, _) = first_two_event_ids(&s);
if let Some(Event::Pitched(p)) = s.events.get_mut(e0) {
p.position = EventPosition::WallClock(WallClockTime(0));
p.duration = EventDuration::WallClock(crate::time::WallClockDuration(1000));
}
}
ContainmentTree => {
// The same voice id under a second instance in the same region.
let dup = s.canvas.regions[0].staff_instances()[0].voices[0].clone();
let new_inst_id: StaffInstanceId = s.identity.mint();
let staff = s.canvas.regions[0].staff_instances()[0].staff;
let mut inst = StaffInstance::new(new_inst_id, staff);
inst.voices.push(dup);
s.canvas.regions[0]
.content
.staff_instances_mut()
.unwrap()
.push(inst);
}
StaffInstanceResolves => {
// Instance references an undeclared staff; keep staff_extent in sync
// so only invariant 6 fires.
let bogus = StaffId::new(replica, 8_888_888);
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0].staff = bogus;
s.canvas.regions[0].staff_extent.staves = vec![bogus];
}
RegionExtents => {
// staff_extent lists a staff that no instance manifests.
let extra = StaffId::new(replica, 7_777_777);
s.canvas.regions[0].staff_extent.staves.push(extra);
}
MeasureSingleInstance => {
// The same measure id in two instances. Needs two instances; add a
// second instance carrying a measure that already exists in the
// first (build both measures with one shared id).
let mid = crate::ids::MeasureId::new(replica, 4_242_424);
let r = &mut s.canvas.regions[0];
let staff = r.staff_instances()[0].staff;
let measure = crate::graph::Measure {
id: mid,
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
r.content.staff_instances_mut().unwrap()[0]
.measures
.push(measure.clone());
let new_inst_id: StaffInstanceId = s.identity.mint();
let mut inst = StaffInstance::new(new_inst_id, staff);
inst.measures.push(measure);
// Avoid a second-staff-in-region collision (invariant 6) by giving
// this instance a distinct, declared staff.
let staff2: StaffId = s.identity.mint();
inst.staff = staff2;
s.staves.push(Staff {
id: staff2,
name: String::from("s2"),
abbreviation: None,
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]
.content
.staff_instances_mut()
.unwrap()
.push(inst);
}
AnchorOffsetModel => {
// A spanner anchored to the metric region with a wall-clock offset.
let rid = first_region_id(&s);
let staff = s.staves[0].id;
s.cross_cutting.spanners.push(Spanner {
id: SpannerId::new(replica, 1),
start: TimeAnchor::Region {
id: rid,
edge: RegionEdge::Start,
offset: AnchorOffset::WallClock(crate::time::WallClockDuration(5)),
},
end: TimeAnchor::WallClock {
time: WallClockTime(10),
},
staves: vec![staff],
kind: Default::default(),
style: Default::default(),
});
}
CrossCuttingRefsResolve => {
// A slur pointing at an event that does not exist.
let ghost = EventId::new(replica, 6_666_666);
s.cross_cutting.slurs.push(Slur {
id: SlurId::new(replica, 1),
start_event: ghost,
end_event: ghost,
kind: Default::default(),
curvature_override: None,
style: Default::default(),
});
}
UniqueIdentifiers => {
// Two staves with the same id.
let dup = s.staves[0].clone();
s.staves.push(dup);
}
PitchIdUnique => {
// Reuse event 0's pitch id inside event 1's chord.
let (e0, e1) = first_two_event_ids(&s);
let pid = first_pitch_id(&s, e0);
if let Some(Event::Pitched(p)) = s.events.get_mut(e1) {
p.pitches.push(cmn_identified_pitch(pid, 0));
}
}
SpellingScopeResolves => {
// A spelling attachment targeting a pitch that is neither live nor
// tombstoned.
let ghost = PitchId::new(replica, 5_555_555);
s.spelling_attachments.push(SpellingAttachment {
scope: SpellingScope::Pitch(ghost),
directive: SpellingDirective::Explicit(crate::pitch::PitchSpelling::cmn(
CmnNominal::C,
4,
)),
source: SpellingSource::UserChosen,
priority: 0,
layer: None,
});
}
DecompositionTargetResolves => {
// A decomposition attachment targeting a non-existent event.
let ghost = EventId::new(replica, 4_444_444);
s.decomposition_attachments.push(DecompositionAttachment {
target: ghost,
components: vec![NotatedComponent {
base_value: NoteValue::Whole,
dots: 0,
tuplet: None,
tied_to_next: false,
}],
source: DecompositionSource::Inferred,
});
}
DecompositionSum => {
// Components sum to the wrong total for a real event: the base
// events are quarter notes (1/4), but a whole-note component is 1/1.
let (e0, _) = first_two_event_ids(&s);
s.decomposition_attachments.push(DecompositionAttachment {
target: e0,
components: vec![NotatedComponent {
base_value: NoteValue::Whole,
dots: 0,
tuplet: None,
tied_to_next: false,
}],
source: DecompositionSource::Inferred,
});
}
TupletSum => {
// A tuplet over two 1/4 events claiming a required total of 1/1.
let (e0, e1) = first_two_event_ids(&s);
s.cross_cutting.tuplets.push(Tuplet {
id: TupletId::new(replica, 1),
ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"),
members: vec![e0, e1],
parent: None,
required_total: MusicalDuration::whole(),
});
}
TiePairing => {
// A tie pairing a pitch that is not in the start event.
let (e0, e1) = first_two_event_ids(&s);
let end_pid = first_pitch_id(&s, e1);
let ghost = PitchId::new(replica, 3_333_333);
s.cross_cutting.ties.push(Tie {
id: TieId::new(replica, 1),
start_event: e0,
end_event: e1,
pitch_pairing: Some(vec![(ghost, end_pid)]),
class: TieClass::Editorial,
style: Default::default(),
});
}
VoiceOriginConsistent => {
// A user-declared voice that uses the reserved SYSTEM_DERIVED
// replica namespace.
let bad = VoiceId::new(ReplicaId::SYSTEM_DERIVED, 1);
let inst = &mut s.canvas.regions[0].content.staff_instances_mut().unwrap()[0];
inst.voices.push(Voice::user(bad));
}
BarlineGroupSameRegion => {
// A barline-alignment group referencing an instance outside the
// region.
let outside = StaffInstanceId::new(replica, 2_222_222);
let gid = crate::ids::BarlineAlignmentGroupId::new(replica, 1);
if let RegionContent::StaffBased(c) = &mut s.canvas.regions[0].content {
c.barline_alignment_groups
.push(crate::graph::BarlineAlignmentGroup {
id: gid,
members: vec![crate::graph::BarlineAlignmentMember {
staff_instance: outside,
measure: crate::ids::MeasureId::new(replica, 1),
position: crate::time::MeasurePosition::Start,
}],
});
}
}
MeasureMeterConsistency => {
// A measure whose declared time signature disagrees with the
// effective grid's active signature at its start (Genesis
// tranche G3b, contract pins 6/6c/9b's agreement clause). Both
// anchored at the region's start (`TimeAnchor::Region`) so the
// comparison is c4-comparable and determinate.
use crate::graph::{BeatGroup, MeterChange, MetricGrid, PowerOfTwo, TimeSignature};
let sig_active: crate::ids::TimeSignatureId = s.identity.mint();
let sig_declared: crate::ids::TimeSignatureId = s.identity.mint();
let measure_duration = MusicalDuration::whole();
let beat_groups = vec![BeatGroup {
duration: measure_duration.clone(),
subdivision: None,
accent: 0,
}];
let display = crate::graph::TimeSignatureDisplay::Standard {
numerator: 4,
denominator: PowerOfTwo::new(4).unwrap(),
};
s.time_signatures.push(
TimeSignature::new(
sig_active,
display.clone(),
measure_duration.clone(),
beat_groups.clone(),
)
.unwrap(),
);
s.time_signatures.push(
TimeSignature::new(sig_declared, display, measure_duration, beat_groups).unwrap(),
);
let region_id = s.canvas.regions[0].id;
let region_start = TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
};
if let RegionContent::StaffBased(content) = &mut s.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: region_start.clone(),
time_signature: sig_active,
}],
});
content.staff_instances[0].measures.push(Measure {
id: MeasureId::new(replica, 5_000_000),
start: region_start,
time_signature: Some(sig_declared),
explicit_number: Some(1),
number_visibility: Default::default(),
});
}
}
}
s
}
/// A coarse size metric: the number of structural elements in the graph.
pub fn element_count(s: &Score) -> usize {
let mut n = s.staves.len()
+ s.canvas.regions.len()
+ s.events.len()
+ s.spelling_attachments.len()
+ s.decomposition_attachments.len()
+ s.cross_cutting.slurs.len()
+ s.cross_cutting.ties.len()
+ s.cross_cutting.beams.len()
+ s.cross_cutting.tuplets.len()
+ s.cross_cutting.spanners.len();
for r in &s.canvas.regions {
for si in r.staff_instances() {
n += 1 + si.voices.len();
}
}
n
}
/// Removes `events` from a voice's listing and from the arena, keeping the
/// arena/voice consistent during a shrink step.
fn drop_events(score: &mut Score, events: &[EventId]) {
for e in events {
score.events.remove(*e);
}
for r in &mut score.canvas.regions {
if let Some(insts) = r.content.staff_instances_mut() {
for si in insts.iter_mut() {
for v in &mut si.voices {
v.events.retain(|x| !events.contains(x));
}
}
}
}
}
/// Produces one-element-smaller candidate clones of `score`, each kept
/// internally consistent (removing a container also prunes its arena events and
/// staff-extent entry), ordered cheapest-removal first.
fn shrink_candidates(score: &Score) -> Vec<Score> {
let mut out = Vec::new();
// Attachments and cross-cutting structures: free-standing, remove directly.
for i in 0..score.spelling_attachments.len() {
let mut c = score.clone();
c.spelling_attachments.remove(i);
out.push(c);
}
for i in 0..score.decomposition_attachments.len() {
let mut c = score.clone();
c.decomposition_attachments.remove(i);
out.push(c);
}
macro_rules! shrink_vec {
($field:ident) => {
for i in 0..score.cross_cutting.$field.len() {
let mut c = score.clone();
c.cross_cutting.$field.remove(i);
out.push(c);
}
};
}
shrink_vec!(slurs);
shrink_vec!(ties);
shrink_vec!(beams);
shrink_vec!(tuplets);
shrink_vec!(spanners);
// Individual events.
for e in score.events.ids_canonical() {
let mut c = score.clone();
drop_events(&mut c, &[e]);
out.push(c);
}
// Voices (with their events).
for (ri, r) in score.canvas.regions.iter().enumerate() {
for (si_ix, si) in r.staff_instances().iter().enumerate() {
for (vi, v) in si.voices.iter().enumerate() {
let mut c = score.clone();
let evs = v.events.clone();
drop_events(&mut c, &evs);
if let Some(insts) = c.canvas.regions[ri].content.staff_instances_mut() {
insts[si_ix].voices.remove(vi);
}
out.push(c);
}
}
}
// Staff instances (with their events and staff-extent entry).
for (ri, r) in score.canvas.regions.iter().enumerate() {
for (si_ix, si) in r.staff_instances().iter().enumerate() {
let mut c = score.clone();
let evs: Vec<EventId> = si.voices.iter().flat_map(|v| v.events.clone()).collect();
let staff = si.staff;
drop_events(&mut c, &evs);
if let Some(insts) = c.canvas.regions[ri].content.staff_instances_mut() {
insts.remove(si_ix);
}
// Keep the staff-extent consistent unless another instance still
// manifests the staff.
let still = c.canvas.regions[ri]
.staff_instances()
.iter()
.any(|x| x.staff == staff);
if !still {
c.canvas.regions[ri]
.staff_extent
.staves
.retain(|x| *x != staff);
}
out.push(c);
}
}
// Whole regions (with their events).
for (ri, r) in score.canvas.regions.iter().enumerate() {
let mut c = score.clone();
let evs: Vec<EventId> = r
.staff_instances()
.iter()
.flat_map(|si| si.voices.iter().flat_map(|v| v.events.clone()))
.collect();
drop_events(&mut c, &evs);
c.canvas.regions.remove(ri);
out.push(c);
}
// Staves.
for i in 0..score.staves.len() {
let mut c = score.clone();
c.staves.remove(i);
out.push(c);
}
out
}
/// Shrinks a graph that violates `inv` to a small witness that still violates
/// it. Greedy: repeatedly takes the first one-element-smaller candidate that
/// still violates `inv`, to a fixpoint. Necessary structure is retained
/// because a removal that repairs the violation is never taken.
pub fn shrink(score: &Score, inv: GraphInvariant) -> Score {
assert!(
!check_invariant(score, inv).is_empty(),
"shrink starting point must violate the target invariant"
);
let mut best = score.clone();
loop {
let mut improved = false;
for cand in shrink_candidates(&best) {
if !check_invariant(&cand, inv).is_empty() {
best = cand;
improved = true;
break;
}
}
if !improved {
break;
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use crate::check_invariants;
#[test]
fn positive_corpus_runs_clean() {
// The hand-off gate: the arbitrary-graph corpus is well-formed.
for score in arbitrary_graph_corpus(500, 0xA11CE) {
let v = check_invariants(&score);
assert!(v.is_empty(), "valid graph reported violations: {v:?}");
}
}
#[test]
fn rich_corpus_runs_clean() {
// The breadth corpus: concurrent metric/proportional/aleatoric regions,
// measures, a triplet, a tie, a spanner, marker, chord symbol,
// decomposition, and tombstones — all well-formed.
for seed in 0..200u64 {
let score = valid_score_rich(seed.wrapping_mul(0x9E37_79B9));
let v = check_invariants(&score);
assert!(v.is_empty(), "rich graph reported violations: {v:?}");
}
// Confirm it actually exercises the broad features (not silently empty).
let s = valid_score_rich(1);
assert_eq!(s.canvas.regions.len(), 3);
assert_eq!(s.cross_cutting.tuplets.len(), 1);
assert_eq!(s.cross_cutting.ties.len(), 1);
assert_eq!(s.cross_cutting.spanners.len(), 1);
assert_eq!(s.decomposition_attachments.len(), 1);
assert!(!s.tombstoned_pitches.is_empty());
assert!(s.events.len() >= 7);
}
#[test]
fn every_invariant_has_a_negative_generator() {
for inv in GraphInvariant::all() {
let s = violating_score(inv, 0x1234_5678);
let violations = check_invariant(&s, inv);
assert!(
!violations.is_empty(),
"negative generator for {inv:?} did not violate it; full report: {:?}",
check_invariants(&s)
);
}
}
/// **P13-S16 touch row 8.** `violating_score`'s invariant-21 arm breaks the
/// **S→G** direction ONLY — in the raw fixture **and after shrinking**.
///
/// Invariant 21's two directions carry the same `GraphInvariant`, so every
/// `all()`-driven test in this module is satisfied by either one and **none
/// can observe which**. This is the only guard on the generator's direction,
/// and `m41b` is the only permanent guard on the other direction being
/// dispatched at all.
///
/// **The shrunk leg is not redundant.** `shrink` rebuilds the witness, and
/// nothing in its contract preserves *which way* the pair disagrees: a shrink
/// that cleared the staff's `group` while leaving it listed in `members`
/// would flip the direction, still violate invariant 21, and satisfy
/// `every_invariant_shrinks_to_a_small_witness` and `shrink_is_idempotent`
/// alike.
///
/// **Mutation (M6a):** delete the `check_staff_names_absent_group` call from
/// `check_invariants`; both legs report nothing and the cardinality
/// assertions print `0` against `1`. Under **M6b** — deleting the G→S arm —
/// this test must **pass**, and that asymmetry is the direction claim.
#[test]
fn invariant_21_negative_generator_breaks_staff_to_group_only() {
let inv = GraphInvariant::StaffGroupMembershipAgreement;
// The two legs are built and checked SEQUENTIALLY, and the raw leg is
// fully validated before `shrink` is ever called.
//
// Building both in one array would evaluate `shrink` first — Rust
// constructs every element before the loop body runs — and `shrink`
// opens with `assert!(!check_invariant(score, inv).is_empty())`. Under
// M6a that check returns empty, so shrink PANICS on its own entry
// assertion before the raw leg's cardinality assertion executes, and M6a
// would report "shrink starting point must violate the target invariant"
// instead of the pinned `0` against `1`. **A panic upstream of the
// pinned assertion destroys the evidence the mutation owes.**
let raw = violating_score(inv, 0x2121_2121);
assert_breaks_staff_to_group_only("raw", &raw, inv);
let shrunk = shrink(&raw, inv);
assert_breaks_staff_to_group_only("shrunk", &shrunk, inv);
}
/// Pin 6a's three properties for one leg of
/// `invariant_21_negative_generator_breaks_staff_to_group_only`: exact
/// cardinality, the S→G witness naming both ids, and the G→S direction
/// holding — each asserted rather than implied.
fn assert_breaks_staff_to_group_only(leg: &str, s: &Score, inv: GraphInvariant) {
// Bound before any assertion, and read from the score so both legs
// survive shrinking rather than hardcoding the generator's counter. The
// ids are formatted here so this helper needs no extra id imports; the
// named group's `members` is carried as a value because the G->S claim
// must be checked against the FIXTURE, not against the checker's output.
let named = s.staves.iter().find_map(|staff| {
staff.group.map(|group| {
let members = s
.staff_groups
.iter()
.find(|candidate| candidate.id == group)
.map(|candidate| candidate.members.clone());
(format!("{:?}", staff.id), format!("{group:?}"), members)
})
});
let violations = check_invariants(s);
// First, because it is the assertion M6a trips.
assert_eq!(
violations.len(),
1,
"{leg}: expected exactly the invariant-21 S->G violation and nothing \
else, got {violations:?}"
);
assert_eq!(
violations[0].invariant, inv,
"{leg}: the single violation must be invariant 21, got {violations:?}"
);
assert!(
violations[0].witness.starts_with("S->G:"),
"{leg}: the generator must break the S->G direction only — a flipped \
direction still violates invariant 21 and no other test would \
notice; got {violations:?}"
);
let (staff_id, group_id, group_members) = named.unwrap_or_else(|| {
panic!("{leg}: the fixture must have a staff naming a group; got {violations:?}")
});
assert!(
violations[0].witness.contains(&staff_id) && violations[0].witness.contains(&group_id),
"{leg}: the witness must name both {staff_id} and {group_id}, got \
{violations:?}"
);
// The opposite direction holds, asserted against the FIXTURE. Checking
// only that no `G->S:` witness was emitted would pass **vacuously under
// M6b**: with the G->S arm deleted no such witness can appear whatever the
// fixture holds, so a flipped or both-directions fixture would slip
// through the leg that is supposed to guarantee the direction. The empty
// `members` is the checker-independent fact, and `m41` asserts it the
// same way.
assert_eq!(
group_members.as_deref(),
Some(&[][..]),
"{leg}: fixture — group {group_id} must list nobody, so nothing can \
disagree G->S; got {group_members:?}"
);
assert!(
!violations.iter().any(|v| v.witness.starts_with("G->S:")),
"{leg}: the G->S direction must hold, got {violations:?}"
);
}
#[test]
fn every_invariant_shrinks_to_a_small_witness() {
for inv in GraphInvariant::all() {
let big = violating_score(inv, 0x0F0F_0F0F);
let before = element_count(&big);
let small = shrink(&big, inv);
let after = element_count(&small);
// Still violates the target.
assert!(
!check_invariant(&small, inv).is_empty(),
"{inv:?}: shrunk witness no longer violates the invariant"
);
// Never grows, and the witness is genuinely small.
assert!(after <= before, "{inv:?}: shrink grew the graph");
assert!(
after <= 16,
"{inv:?}: witness not minimized enough ({after} elements)"
);
}
}
#[test]
fn shrink_is_idempotent() {
for inv in GraphInvariant::all() {
let s = shrink(&violating_score(inv, 7), inv);
let again = shrink(&s, inv);
assert_eq!(
element_count(&s),
element_count(&again),
"{inv:?}: shrink not at a fixpoint"
);
}
}
#[test]
fn negative_generators_are_reasonably_targeted() {
// Each corruption should be tightly scoped: at most a couple of
// invariants fire, and the target is always among them. (Some
// invariants overlap by construction — e.g. duplicating an id breaks
// both uniqueness and the containment tree — so we allow a small set.)
for inv in GraphInvariant::all() {
let s = violating_score(inv, 99);
let all = check_invariants(&s);
let kinds: std::collections::BTreeSet<_> = all.iter().map(|v| v.invariant).collect();
assert!(kinds.contains(&inv), "{inv:?} not among {kinds:?}");
assert!(
kinds.len() <= 3,
"{inv:?} corruption fired too many invariants: {kinds:?}"
);
}
}
}