epiphany/crates/epiphany-testkit/src/corpus.rs

1419 lines
47 KiB
Rust

//! **F3 — the representative score corpus + kind-by-kind eligibility-taxonomy
//! harness** (Agent F, for Agent H; `spec/PHASE2_F_WEEK0_WORKLIST.md` item F3,
//! `spec/PHASE2_QUICKSTART.md` §H "What H produces, by event kind").
//!
//! H's whole acceptance rests on "F's representative corpus (>20 fixtures)
//! spanning common / edge / torture cases" exercising the event-kind
//! **eligibility taxonomy** — and H develops against it, not just is tested by
//! it. This module is that corpus.
//!
//! Every fixture is:
//! * **deterministic** — built from a fixed replica seed, no platform entropy
//! (Appendix D §"Randomness"), so a failure reproduces exactly; and
//! * **`check_invariants`-clean** — asserted by `every_fixture_is_invariant_clean`
//! in this module's tests, so a malformed fixture can never mask a real H
//! regression.
//!
//! The harness does **not** re-derive the taxonomy: it runs Agent H's own
//! [`epiphany_core::derive_annotations`] and reads its
//! [`epiphany_core::TaxonomyReport`], then (a) **independently recounts** events
//! by kind and cross-checks H's counts (so a miscount is caught, not trusted),
//! and (b) aggregates per-bucket counts across the corpus and asserts every
//! taxonomy bucket is non-empty or explicitly deferred — "ineligible" is then
//! explicit and counted, never silently absent.
use std::collections::BTreeSet;
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, 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,
PitchId, PitchSpaceId, PitchSpacePosition, RegionId, ReplicaId, ScalePosition, StaffId,
StaffInstanceId, TimeSignatureId, TuningReference, TupletId, VoiceId,
};
// ===========================================================================
// The eligibility-taxonomy buckets
// ===========================================================================
/// One bucket of [`TaxonomyReport`]. Every event and every embedded pitch lands
/// in exactly the buckets that apply; the corpus must populate each (or list it
/// in [`DEFERRED_BUCKETS`] with a reason).
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Bucket {
PitchedEvents,
UnpitchedEvents,
RestEvents,
TrajectoryEvents,
GraphicEvents,
IndeterminateEvents,
CueEvents,
SpellingsInferred,
SpellingsAuthored,
SpellingUnavailable,
DecompositionsInferred,
DecompositionSkippedNonmusical,
DecompositionDeferredNonmetric,
DecompositionInapplicable,
DecompositionUngriddable,
}
impl Bucket {
/// All buckets, in declaration order.
pub fn all() -> [Bucket; 15] {
use Bucket::*;
[
PitchedEvents,
UnpitchedEvents,
RestEvents,
TrajectoryEvents,
GraphicEvents,
IndeterminateEvents,
CueEvents,
SpellingsInferred,
SpellingsAuthored,
SpellingUnavailable,
DecompositionsInferred,
DecompositionSkippedNonmusical,
DecompositionDeferredNonmetric,
DecompositionInapplicable,
DecompositionUngriddable,
]
}
/// This bucket's count in a report.
pub fn count(self, t: &TaxonomyReport) -> usize {
use Bucket::*;
match self {
PitchedEvents => t.pitched_events,
UnpitchedEvents => t.unpitched_events,
RestEvents => t.rest_events,
TrajectoryEvents => t.trajectory_events,
GraphicEvents => t.graphic_events,
IndeterminateEvents => t.indeterminate_events,
CueEvents => t.cue_events,
SpellingsInferred => t.spellings_inferred,
SpellingsAuthored => t.spellings_authored,
SpellingUnavailable => t.spelling_unavailable,
DecompositionsInferred => t.decompositions_inferred,
DecompositionSkippedNonmusical => t.decomposition_skipped_nonmusical,
DecompositionDeferredNonmetric => t.decomposition_deferred_nonmetric,
DecompositionInapplicable => t.decomposition_inapplicable,
DecompositionUngriddable => t.decomposition_ungriddable,
}
}
pub fn label(self) -> &'static str {
use Bucket::*;
match self {
PitchedEvents => "pitched_events",
UnpitchedEvents => "unpitched_events",
RestEvents => "rest_events",
TrajectoryEvents => "trajectory_events",
GraphicEvents => "graphic_events",
IndeterminateEvents => "indeterminate_events",
CueEvents => "cue_events",
SpellingsInferred => "spellings_inferred",
SpellingsAuthored => "spellings_authored",
SpellingUnavailable => "spelling_unavailable",
DecompositionsInferred => "decompositions_inferred",
DecompositionSkippedNonmusical => "decomposition_skipped_nonmusical",
DecompositionDeferredNonmetric => "decomposition_deferred_nonmetric",
DecompositionInapplicable => "decomposition_inapplicable",
DecompositionUngriddable => "decomposition_ungriddable",
}
}
}
/// Buckets the clean corpus is *not* required to populate, each with a written
/// reason. Empty today: every bucket is reachable by invariant-clean input and
/// is exercised by a dedicated fixture (see `DECISIONS.md` F3). If a future
/// invariant change makes a bucket unreachable, list it here rather than
/// dropping the coverage assertion.
pub const DEFERRED_BUCKETS: &[Bucket] = &[];
/// The "unusual outcome" buckets: loss, deferral, inapplicability, or
/// unavailability. Unlike the success buckets (`*Events`, `Spellings*`,
/// `DecompositionsInferred`), which vary freely, a fixture may populate one of
/// these **only if it declares it** in [`Fixture::expect`] — the corpus harness
/// enforces that exactly (`classify_corpus` step 5b). This makes `expect` an
/// exact whitelist for precisely the buckets where a silent *under-emission*
/// regression would otherwise hide: an event that should have decomposed but
/// instead slips into `ungriddable`/`skipped` lights up an undeclared bucket,
/// rather than vanishing while the success count merely shrinks and the
/// event-total cross-check still balances.
const UNUSUAL_BUCKETS: &[Bucket] = &[
Bucket::SpellingUnavailable,
Bucket::DecompositionSkippedNonmusical,
Bucket::DecompositionDeferredNonmetric,
Bucket::DecompositionInapplicable,
Bucket::DecompositionUngriddable,
];
/// The fixture tier: common notation, edge cases, and torture cases — the three
/// the acceptance criterion names.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Tier {
Common,
Edge,
Torture,
}
/// One tagged corpus fixture: a deterministic builder plus the taxonomy buckets
/// it is asserted to populate.
pub struct Fixture {
pub name: &'static str,
pub tier: Tier,
pub build: fn() -> Score,
/// Buckets this fixture must drive non-zero (validated per-fixture, and
/// aggregated for corpus coverage). For the crate-private `UNUSUAL_BUCKETS` this list is
/// also an *exact whitelist*: a fixture that lands an event in a
/// loss/deferral bucket it did not declare fails the harness.
pub expect: &'static [Bucket],
}
// ===========================================================================
// The corpus
// ===========================================================================
/// The representative corpus: ≥20 invariant-clean fixtures spanning the
/// eligibility taxonomy across common / edge / torture tiers, plus the existing
/// positive generators so F's taxonomy harness runs over the same graphs Agents
/// H and I develop and render against.
pub fn corpus() -> Vec<Fixture> {
use Bucket::*;
use Tier::*;
vec![
// ---- Common: ordinary tonal notation. -----------------------------
Fixture {
name: "c_major_scale",
tier: Common,
build: fx_c_major_scale,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "d_major_scale",
tier: Common,
build: fx_d_major_scale,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "b_flat_major_scale",
tier: Common,
build: fx_b_flat_major_scale,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "c_triad_chord",
tier: Common,
build: fx_c_triad_chord,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "notes_and_rests",
tier: Common,
build: fx_notes_and_rests,
expect: &[
PitchedEvents,
RestEvents,
SpellingsInferred,
DecompositionsInferred,
],
},
Fixture {
name: "dotted_half_and_quarter",
tier: Common,
build: fx_dotted_half_and_quarter,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "two_voice_counterpoint",
tier: Common,
build: fx_two_voice_counterpoint,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "meter_three_four",
tier: Common,
build: fx_meter_three_four,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
// ---- Edge: rhythmic and spelling difficulty. ----------------------
Fixture {
name: "syncopation_offbeat",
tier: Edge,
build: fx_syncopation_offbeat,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "mixed_rhythm",
tier: Edge,
build: fx_mixed_rhythm,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "tie_across_barline",
tier: Edge,
build: fx_tie_across_barline,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "triplet_eighths",
tier: Edge,
build: fx_triplet_eighths,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "quintuplet_sixteenths",
tier: Edge,
build: fx_quintuplet_sixteenths,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "unpitched_percussion",
tier: Edge,
build: fx_unpitched_percussion,
expect: &[UnpitchedEvents, DecompositionsInferred],
},
Fixture {
name: "respell_override",
tier: Edge,
build: fx_respell_override,
expect: &[PitchedEvents, SpellingsAuthored, DecompositionsInferred],
},
Fixture {
name: "ascending_chromatic_run",
tier: Edge,
build: fx_ascending_chromatic_run,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
// ---- Torture: non-pitched kinds, deferred regions, ungriddable. ----
Fixture {
name: "ji_spelling_unavailable",
tier: Torture,
build: fx_ji_spelling_unavailable,
expect: &[PitchedEvents, SpellingUnavailable, DecompositionsInferred],
},
Fixture {
name: "proportional_region",
tier: Torture,
build: fx_proportional_region,
expect: &[
PitchedEvents,
SpellingsInferred,
DecompositionDeferredNonmetric,
],
},
Fixture {
name: "aleatoric_region",
tier: Torture,
build: fx_aleatoric_region,
expect: &[
PitchedEvents,
SpellingsInferred,
DecompositionDeferredNonmetric,
],
},
Fixture {
name: "trajectory_glissando",
tier: Torture,
build: fx_trajectory_glissando,
expect: &[
TrajectoryEvents,
SpellingsInferred,
DecompositionInapplicable,
],
},
Fixture {
name: "graphic_event",
tier: Torture,
build: fx_graphic_event,
expect: &[GraphicEvents, DecompositionInapplicable],
},
Fixture {
name: "indeterminate_event",
tier: Torture,
build: fx_indeterminate_event,
expect: &[IndeterminateEvents, DecompositionInapplicable],
},
Fixture {
name: "cue_event",
tier: Torture,
build: fx_cue_event,
expect: &[CueEvents, PitchedEvents, DecompositionInapplicable],
},
Fixture {
name: "off_grid_ungriddable",
tier: Torture,
build: fx_off_grid_ungriddable,
expect: &[PitchedEvents, SpellingsInferred, DecompositionUngriddable],
},
Fixture {
name: "sub_sixtyfourth_ungriddable",
tier: Torture,
build: fx_sub_sixtyfourth_ungriddable,
expect: &[PitchedEvents, SpellingsInferred, DecompositionUngriddable],
},
Fixture {
name: "grace_zero_duration",
tier: Torture,
build: fx_grace_zero_duration,
expect: &[
PitchedEvents,
SpellingsInferred,
DecompositionSkippedNonmusical,
],
},
// ---- Existing positive generators, re-used so the taxonomy harness
// runs over the exact graphs H and I build/render against. -------
Fixture {
name: "gen_valid_score",
tier: Common,
build: fx_gen_valid_score,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
Fixture {
name: "gen_valid_score_rich",
tier: Torture,
build: fx_gen_valid_score_rich,
expect: &[PitchedEvents, DecompositionDeferredNonmetric],
},
Fixture {
name: "gen_ten_measure_single_staff",
tier: Common,
build: fx_gen_ten_measure_single_staff,
expect: &[PitchedEvents, SpellingsInferred, DecompositionsInferred],
},
]
}
// ===========================================================================
// Construction helpers
// ===========================================================================
/// A one-region, one-staff score under construction. Single-region builders
/// cover the whole taxonomy; the few multi-region cases come from the wrapped
/// generators.
struct OneStaff {
idc: IdentityContext,
staff: StaffId,
instrument: InstrumentId,
region: RegionId,
instance: StaffInstanceId,
arena: EventArena,
voices: Vec<Voice>,
cross_cutting: epiphany_core::CrossCuttingRegistry,
spelling_attachments: Vec<SpellingAttachment>,
time_signatures: Vec<TimeSignature>,
}
impl OneStaff {
fn new(replica: u64) -> Self {
let mut idc = IdentityContext::new(ReplicaId(replica));
let staff: StaffId = idc.mint();
let instrument: InstrumentId = idc.mint();
let region: RegionId = idc.mint();
let instance: StaffInstanceId = idc.mint();
OneStaff {
idc,
staff,
instrument,
region,
instance,
arena: EventArena::new(),
voices: Vec::new(),
cross_cutting: epiphany_core::CrossCuttingRegistry::default(),
spelling_attachments: Vec::new(),
time_signatures: Vec::new(),
}
}
/// Mints a fresh voice and returns its id.
fn voice(&mut self) -> VoiceId {
let id: VoiceId = self.idc.mint();
self.voices.push(Voice::user(id));
id
}
/// Inserts an event into the arena and appends it to `voice`'s ordered list.
/// Callers add events in ascending position order (invariant 3).
fn add(&mut self, voice: VoiceId, e: Event) {
let eid = e.id();
self.arena.insert(e).expect("fresh event id");
self.voices
.iter_mut()
.find(|v| v.id == voice)
.expect("voice exists")
.events
.push(eid);
}
/// Assembles the score under `time_model`.
fn finish(self, time_model: RegionTimeModel) -> Score {
let OneStaff {
idc,
staff,
instrument,
region,
instance,
arena,
voices,
cross_cutting,
spelling_attachments,
time_signatures,
} = self;
let mut si = StaffInstance::new(instance, staff);
si.voices = voices;
let region_obj = Region {
id: region,
time_model,
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![si],
..Default::default()
}),
time_extent: TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(10_000_000),
},
},
staff_extent: StaffExtent {
staves: vec![staff],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
let mut score = Score::empty(idc.clone());
score.identity = idc;
score.staves = vec![Staff {
id: staff,
name: String::from("F-corpus"),
abbreviation: None,
instrument,
default_staff_lines: StaffLineConfiguration::default(),
group: None,
default_clef: epiphany_core::Clef::treble(),
}];
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;
score.time_signatures = time_signatures;
score.canvas = Canvas {
regions: vec![region_obj],
..Default::default()
};
score
}
}
fn metric() -> RegionTimeModel {
RegionTimeModel::Metric(MetricTimeModel::default())
}
fn proportional() -> RegionTimeModel {
RegionTimeModel::Proportional(ProportionalTimeModel {
duration: WallClockDuration(1_000_000),
})
}
fn aleatoric_musical() -> RegionTimeModel {
RegionTimeModel::Aleatoric(AleatoricTimeModel {
ordering: Default::default(),
anchoring: AleatoricAnchoringDiscipline::Musical,
bounds: Default::default(),
duration_hint: WallClockDuration(1_000_000),
})
}
/// A `cmn-12` integer (chromatic / 12-EDO) pitch at absolute 12-TET `semitone`
/// (C4 = 48). The spelling algorithm must *decide* its spelling — there is no
/// authored CMN letter to preserve.
fn integer_pitch(semitone: i32) -> Pitch {
Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Integer {
space_size: 12,
index: semitone,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
}
}
/// A just-intonation pitch whose 12-TET class is not determinable from the
/// position alone: spelling unavailable.
fn ji_pitch() -> Pitch {
Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("ji-5limit"),
position: PitchSpacePosition::JiVector {
components: vec![0, 1, -1],
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
}
}
fn ipitch(id: PitchId, pitch: Pitch) -> IdentifiedPitch {
IdentifiedPitch { id, pitch }
}
fn mpos(n: i64, d: i64) -> EventPosition {
EventPosition::Musical(MusicalPosition(rt(n, d)))
}
fn mdur(n: i64, d: i64) -> EventDuration {
EventDuration::Musical(MusicalDuration(rt(n, d)))
}
fn rt(n: i64, d: i64) -> epiphany_core::RationalTime {
epiphany_core::RationalTime::new(n, d).unwrap()
}
fn pitched(
id: EventId,
voice: VoiceId,
position: EventPosition,
duration: EventDuration,
pitches: Vec<IdentifiedPitch>,
) -> Event {
Event::Pitched(epiphany_core::PitchedEvent {
id,
voice,
position,
duration,
pitches,
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
})
}
/// A single integer-pitched note: mints the event + pitch and adds it.
fn add_note(
b: &mut OneStaff,
voice: VoiceId,
pos: EventPosition,
dur: EventDuration,
semitone: i32,
) {
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
voice,
pitched(
eid,
voice,
pos,
dur,
vec![ipitch(pid, integer_pitch(semitone))],
),
);
}
// ===========================================================================
// Common-tier fixtures
// ===========================================================================
fn metric_line(replica: u64, semitones: &[i32]) -> Score {
let mut b = OneStaff::new(replica);
let v = b.voice();
for (i, &s) in semitones.iter().enumerate() {
add_note(&mut b, v, mpos(i as i64, 4), mdur(1, 4), s);
}
b.finish(metric())
}
fn fx_c_major_scale() -> Score {
// C4 D4 E4 F4 G4 A4 B4 C5 — all naturals (line-of-fifths stays diatonic).
metric_line(0xF001, &[48, 50, 52, 53, 55, 57, 59, 60])
}
fn fx_d_major_scale() -> Score {
// D major: D E F# G A B C# D — sharps in a sharp context.
metric_line(0xF002, &[50, 52, 54, 55, 57, 59, 61, 62])
}
fn fx_b_flat_major_scale() -> Score {
// Bb major: Bb C D Eb F G A — flats in a flat context.
metric_line(0xF003, &[46, 48, 50, 51, 53, 55, 57])
}
fn fx_c_triad_chord() -> Score {
// A C-E-G triad as one event (chord), plus a following G — all spelled.
let mut b = OneStaff::new(0xF004);
let v = b.voice();
let eid: EventId = b.idc.mint();
let p1: PitchId = b.idc.mint();
let p2: PitchId = b.idc.mint();
let p3: PitchId = b.idc.mint();
b.add(
v,
pitched(
eid,
v,
mpos(0, 4),
mdur(1, 4),
vec![
ipitch(p1, integer_pitch(48)),
ipitch(p2, integer_pitch(52)),
ipitch(p3, integer_pitch(55)),
],
),
);
add_note(&mut b, v, mpos(1, 4), mdur(1, 4), 55);
b.finish(metric())
}
fn fx_notes_and_rests() -> Score {
// Note, rest, note, rest — rests decompose but are not spelled.
let mut b = OneStaff::new(0xF005);
let v = b.voice();
add_note(&mut b, v, mpos(0, 4), mdur(1, 4), 48);
let r1: EventId = b.idc.mint();
b.add(v, rest_ev(r1, v, mpos(1, 4), mdur(1, 4)));
add_note(&mut b, v, mpos(2, 4), mdur(1, 4), 52);
let r2: EventId = b.idc.mint();
b.add(v, rest_ev(r2, v, mpos(3, 4), mdur(1, 4)));
b.finish(metric())
}
fn fx_dotted_half_and_quarter() -> Score {
// Dotted half (3/4 on the downbeat) then a quarter — exercises dots.
let mut b = OneStaff::new(0xF006);
let v = b.voice();
add_note(&mut b, v, mpos(0, 4), mdur(3, 4), 48);
add_note(&mut b, v, mpos(3, 4), mdur(1, 4), 50);
b.finish(metric())
}
fn fx_two_voice_counterpoint() -> Score {
// Two voices in one staff instance: independent melodic runs, each spelled.
let mut b = OneStaff::new(0xF007);
let upper = b.voice();
let lower = b.voice();
for (i, &s) in [48, 50, 52, 53].iter().enumerate() {
add_note(&mut b, upper, mpos(i as i64, 4), mdur(1, 4), s);
}
for (i, &s) in [36, 38, 40, 41].iter().enumerate() {
add_note(&mut b, lower, mpos(i as i64, 4), mdur(1, 4), s);
}
b.finish(metric())
}
fn fx_meter_three_four() -> Score {
// 3/4: a declared time signature drives the measure length (exercises H's
// meter-resolution path past the whole-note default).
let mut b = OneStaff::new(0xF008);
let ts_id: TimeSignatureId = b.idc.mint();
let ts = TimeSignature::new(
ts_id,
TimeSignatureDisplay::Standard {
numerator: 3,
denominator: PowerOfTwo::new(4).unwrap(),
},
MusicalDuration(rt(3, 4)),
vec![beat_quarter(), beat_quarter(), beat_quarter()],
)
.expect("3/4 beat groups sum to 3/4");
b.time_signatures.push(ts);
let v = b.voice();
for (i, &s) in [48, 50, 52].iter().enumerate() {
add_note(&mut b, v, mpos(i as i64, 4), mdur(1, 4), s);
}
b.finish(metric())
}
// ===========================================================================
// Edge-tier fixtures
// ===========================================================================
fn fx_syncopation_offbeat() -> Score {
// [1/8, 1/2): an eighth tied to a quarter — a multi-component decomposition.
let mut b = OneStaff::new(0xF101);
let v = b.voice();
add_note(&mut b, v, mpos(1, 8), mdur(3, 8), 48);
b.finish(metric())
}
fn fx_mixed_rhythm() -> Score {
// One bar mixing three note values (eighth, quarter, half) with a syncopated
// eighth-tied-to-quarter. A single fixture that both spans ≥2 note values *and*
// produces a tied multi-component split, so the non-vacuity spread checks keep
// margin beyond the corpus's other rhythmic fixtures.
let mut b = OneStaff::new(0xF205);
let v = b.voice();
add_note(&mut b, v, mpos(0, 8), mdur(1, 8), 48); // eighth on the downbeat
add_note(&mut b, v, mpos(1, 8), mdur(3, 8), 50); // syncopation: eighth + quarter
add_note(&mut b, v, mpos(1, 2), mdur(1, 2), 52); // half on beat 3
b.finish(metric())
}
fn fx_tie_across_barline() -> Score {
// A half note on beat 4 of 4/4 crosses the barline: quarter tied to quarter.
let mut b = OneStaff::new(0xF102);
let v = b.voice();
add_note(&mut b, v, mpos(3, 4), mdur(1, 2), 48);
b.finish(metric())
}
fn fx_triplet_eighths() -> Score {
// A 3:2 eighth-note triplet (each member sounds 1/12, notates as an eighth).
let mut b = OneStaff::new(0xF103);
let v = b.voice();
let tid: TupletId = b.idc.mint();
let mut members = Vec::new();
for k in 0..3i64 {
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
members.push(eid);
b.add(
v,
pitched(
eid,
v,
mpos(k, 12),
mdur(1, 12),
vec![ipitch(pid, integer_pitch(48 + k as i32))],
),
);
}
b.cross_cutting.tuplets.push(Tuplet {
id: tid,
ratio: TupletRatio::new(3, 2).unwrap(),
members,
parent: None,
required_total: MusicalDuration(rt(1, 4)),
});
b.finish(metric())
}
fn fx_quintuplet_sixteenths() -> Score {
// A 5:4 quintuplet of sixteenths (each sounds 1/20, notates as a sixteenth).
let mut b = OneStaff::new(0xF104);
let v = b.voice();
let tid: TupletId = b.idc.mint();
let mut members = Vec::new();
for k in 0..5i64 {
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
members.push(eid);
b.add(
v,
pitched(
eid,
v,
mpos(k, 20),
mdur(1, 20),
vec![ipitch(pid, integer_pitch(48 + k as i32))],
),
);
}
b.cross_cutting.tuplets.push(Tuplet {
id: tid,
ratio: TupletRatio::new(5, 4).unwrap(),
members,
parent: None,
required_total: MusicalDuration(rt(1, 4)),
});
b.finish(metric())
}
fn fx_unpitched_percussion() -> Score {
// Snare-line unpitched events: decomposed, but never pitch-spelled.
let mut b = OneStaff::new(0xF105);
let v = b.voice();
for i in 0..4i64 {
let eid: EventId = b.idc.mint();
b.add(
v,
Event::Unpitched(UnpitchedEvent {
id: eid,
voice: v,
position: mpos(i, 4),
duration: mdur(1, 4),
staff_position: StaffPosition(2),
instrument_member: UnpitchedMemberId(1),
articulations: vec![],
dynamic: None,
stem: StemConfiguration,
grace: None,
}),
);
}
b.finish(metric())
}
fn fx_respell_override() -> Score {
// An integer pc-6 note (which the algorithm spells one way) carries an
// authored UserChosen override that must win (precedence): F#4.
let mut b = OneStaff::new(0xF106);
let v = b.voice();
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
v,
pitched(
eid,
v,
mpos(0, 4),
mdur(1, 4),
vec![ipitch(pid, integer_pitch(54))],
),
);
b.spelling_attachments.push(SpellingAttachment {
scope: SpellingScope::Pitch(pid),
directive: SpellingDirective::Explicit(PitchSpelling {
nominal: SpellingNominal::Cmn(CmnNominal::F),
accidentals: vec![AccidentalId::new("sharp")],
octave: 4,
render_hints: Default::default(),
}),
source: SpellingSource::UserChosen,
priority: 0,
layer: None,
});
b.finish(metric())
}
fn fx_ascending_chromatic_run() -> Score {
// A rising chromatic line: the direction tiebreak prefers sharps.
metric_line(0xF107, &[48, 49, 50, 51, 52])
}
// ===========================================================================
// Torture-tier fixtures
// ===========================================================================
fn fx_ji_spelling_unavailable() -> Score {
// A JI-vector pitch (spelling unavailable) alongside a cmn-12 pitch (spelled).
let mut b = OneStaff::new(0xF201);
let v = b.voice();
add_note(&mut b, v, mpos(0, 4), mdur(1, 4), 48);
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
v,
pitched(
eid,
v,
mpos(1, 4),
mdur(1, 4),
vec![ipitch(pid, ji_pitch())],
),
);
b.finish(metric())
}
fn fx_proportional_region() -> Score {
// Wall-clock events in a proportional region: pitches still spell (identity
// is region-independent); decomposition is deferred (non-metric).
let mut b = OneStaff::new(0xF202);
let v = b.voice();
for k in 0..3i64 {
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
v,
Event::Pitched(epiphany_core::PitchedEvent {
id: eid,
voice: v,
position: EventPosition::WallClock(WallClockTime(k * 1000)),
duration: EventDuration::WallClock(WallClockDuration(1000)),
pitches: vec![ipitch(pid, integer_pitch(48 + 2 * k as i32))],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
}),
);
}
b.finish(proportional())
}
fn fx_aleatoric_region() -> Score {
// A musical-discipline aleatoric region: decomposition deferred, pitches
// still spelled.
let mut b = OneStaff::new(0xF203);
let v = b.voice();
for k in 0..3i64 {
add_note(&mut b, v, mpos(k, 4), mdur(1, 4), 48 + 2 * k as i32);
}
b.finish(aleatoric_musical())
}
fn fx_trajectory_glissando() -> Score {
// A glissando whose endpoints are explicit local pitches: those pitches are
// spelled; the trajectory event itself is not decomposed (inapplicable).
let mut b = OneStaff::new(0xF204);
let v = b.voice();
let eid: EventId = b.idc.mint();
let p_start: PitchId = b.idc.mint();
let p_end: PitchId = b.idc.mint();
b.add(
v,
Event::Trajectory(TrajectoryEvent {
id: eid,
voice: v,
position: mpos(0, 4),
duration: mdur(1, 4),
start: TrajectoryEndpoint::ExplicitPitch(ipitch(p_start, integer_pitch(48))),
end: TrajectoryEndpoint::ExplicitPitch(ipitch(p_end, integer_pitch(55))),
shape: TrajectoryShape::Linear,
display: TrajectoryDisplay,
}),
);
b.finish(metric())
}
fn fx_graphic_event() -> Score {
// A graphic event with no referenced objects: a kind that never decomposes
// or spells (counted as inapplicable).
let mut b = OneStaff::new(0xF205);
let v = b.voice();
let eid: EventId = b.idc.mint();
b.add(
v,
Event::Graphic(GraphicEvent {
id: eid,
voice: v,
position: mpos(0, 4),
duration: mdur(1, 4),
graphics: vec![],
playback_bindings: vec![],
}),
);
b.finish(metric())
}
fn fx_indeterminate_event() -> Score {
// An indeterminate-pitch event (determinate duration): not spelled, not
// decomposed (inapplicable).
let mut b = OneStaff::new(0xF206);
let v = b.voice();
let eid: EventId = b.idc.mint();
b.add(
v,
Event::Indeterminate(IndeterminateEvent {
id: eid,
voice: v,
position: mpos(0, 4),
duration: mdur(1, 4),
indeterminacy: IndeterminacyKind::Pitch,
hints: IndeterminacyHints::default(),
}),
);
b.finish(metric())
}
fn fx_cue_event() -> Score {
// A cue referencing a real (live) source event: the cue itself is
// inapplicable for spelling/decomposition; the sourced pitched note spells.
let mut b = OneStaff::new(0xF207);
let v = b.voice();
let src: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
v,
pitched(
src,
v,
mpos(0, 4),
mdur(1, 4),
vec![ipitch(pid, integer_pitch(48))],
),
);
let cue: EventId = b.idc.mint();
b.add(
v,
Event::Cue(CueEvent {
id: cue,
voice: v,
position: mpos(1, 4),
duration: mdur(1, 4),
source: vec![src],
rendering: CueRendering,
}),
);
b.finish(metric())
}
fn fx_off_grid_ungriddable() -> Score {
// A metric note whose position is off the dyadic grid (1/3) — its duration
// is a clean quarter, but it cannot be barline-aligned, so decomposition is
// honestly ungriddable while the pitch still spells.
let mut b = OneStaff::new(0xF208);
let v = b.voice();
add_note(&mut b, v, mpos(1, 3), mdur(1, 4), 48);
b.finish(metric())
}
fn fx_sub_sixtyfourth_ungriddable() -> Score {
// A 1/128 duration — an exact grid multiple but below the sixty-fourth
// floor: ungriddable (never silently dropped), pitch still spells.
let mut b = OneStaff::new(0xF209);
let v = b.voice();
add_note(&mut b, v, mpos(0, 4), mdur(1, 128), 48);
b.finish(metric())
}
fn fx_grace_zero_duration() -> Score {
// A zero-duration grace note: a determinate musical duration with nothing to
// decompose (skipped as non-musical), but the pitch still spells.
let mut b = OneStaff::new(0xF20A);
let v = b.voice();
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
v,
Event::Pitched(epiphany_core::PitchedEvent {
id: eid,
voice: v,
position: mpos(0, 4),
duration: mdur(0, 1),
pitches: vec![ipitch(pid, integer_pitch(49))],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: Some(GraceKind::Acciaccatura),
}),
);
b.finish(metric())
}
// ---- Wrapped positive generators (fixed seeds). ----------------------------
fn fx_gen_valid_score() -> Score {
epiphany_core::generators::valid_score(0xF301)
}
fn fx_gen_valid_score_rich() -> Score {
epiphany_core::generators::valid_score_rich(0xF302)
}
fn fx_gen_ten_measure_single_staff() -> Score {
crate::fixtures::ten_measure_single_staff(0xF303)
}
/// A one-note metric score (a `cmn-12` integer pitch, pc 6) together with the id
/// of its single pitch — for the merge gate's precedence harness to attach
/// `RespellPitch`-style overrides to.
pub fn override_probe() -> (Score, PitchId) {
let mut b = OneStaff::new(0xF401);
let v = b.voice();
let eid: EventId = b.idc.mint();
let pid: PitchId = b.idc.mint();
b.add(
v,
pitched(
eid,
v,
mpos(0, 4),
mdur(1, 4),
vec![ipitch(pid, integer_pitch(54))],
),
);
(b.finish(metric()), pid)
}
fn rest_ev(id: EventId, voice: VoiceId, position: EventPosition, duration: EventDuration) -> Event {
Event::Rest(epiphany_core::Rest {
id,
voice,
position,
duration,
vertical_position: None,
visible: true,
})
}
fn beat_quarter() -> BeatGroup {
BeatGroup {
duration: MusicalDuration(rt(1, 4)),
subdivision: None,
accent: 1,
}
}
// ===========================================================================
// The classification + counting harness (F3)
// ===========================================================================
/// Independently counts events by kind from the score graph (F's own walk), so
/// H's [`TaxonomyReport`] event counts can be cross-checked rather than trusted.
fn event_kind_counts(score: &Score) -> [usize; 7] {
// [pitched, unpitched, rest, trajectory, graphic, indeterminate, cue]
let mut c = [0usize; 7];
for e in score.events.iter() {
let i = match e {
Event::Pitched(_) => 0,
Event::Unpitched(_) => 1,
Event::Rest(_) => 2,
Event::Trajectory(_) => 3,
Event::Graphic(_) => 4,
Event::Indeterminate(_) => 5,
Event::Cue(_) => 6,
};
c[i] += 1;
}
c
}
/// Per-fixture taxonomy outcome.
pub struct FixtureReport {
pub name: &'static str,
pub tier: Tier,
pub annotations: DerivedAnnotations,
}
/// Runs H's derivation over the whole corpus, validating each fixture and
/// returning the per-fixture reports and the corpus-wide bucket totals.
pub struct CorpusReport {
pub fixtures: Vec<FixtureReport>,
/// Aggregated bucket totals across the corpus.
pub totals: Vec<(Bucket, usize)>,
}
impl CorpusReport {
pub fn total(&self, b: Bucket) -> usize {
self.totals
.iter()
.find(|(x, _)| *x == b)
.map(|(_, n)| *n)
.unwrap_or(0)
}
}
/// Classifies and counts the whole corpus. This is the F3 entry point: it builds
/// every fixture, asserts each is invariant-clean, derives H's annotations,
/// cross-checks H's event-kind counts against an independent walk, validates each
/// fixture's declared expected buckets, and aggregates per-bucket totals.
pub fn classify_corpus() -> CorpusReport {
let profile = PrePassProfile::default();
let mut fixtures = Vec::new();
let mut totals: Vec<(Bucket, usize)> = Bucket::all().iter().map(|b| (*b, 0usize)).collect();
for f in corpus() {
let score = (f.build)();
// 1. Invariant-clean: a malformed fixture must never mask an H regression.
let v = check_invariants(&score);
assert!(
v.is_empty(),
"corpus fixture `{}` has invariant violations: {v:?}",
f.name
);
// 2. Derive H's annotations + taxonomy.
let ann = derive_annotations(&score, &profile)
.expect("the default pre-pass algorithms are supported");
let t = &ann.taxonomy;
// 3. Cross-check H's event-kind counts against an independent walk.
let mine = event_kind_counts(&score);
let his = [
t.pitched_events,
t.unpitched_events,
t.rest_events,
t.trajectory_events,
t.graphic_events,
t.indeterminate_events,
t.cue_events,
];
assert_eq!(
mine, his,
"fixture `{}`: H's event-kind taxonomy {his:?} disagrees with an \
independent walk {mine:?}",
f.name
);
// 4. Every embedded pitch and every event is accounted for in some bucket
// (nothing silently absent): the spelling buckets cover every
// determinable embedded pitch, and the decomposition buckets cover
// every event.
let event_total: usize = his.iter().sum();
let decomp_total = t.decompositions_inferred
+ t.decomposition_skipped_nonmusical
+ t.decomposition_deferred_nonmetric
+ t.decomposition_inapplicable
+ t.decomposition_ungriddable;
assert_eq!(
event_total, decomp_total,
"fixture `{}`: {decomp_total} events bucketed for decomposition but \
{event_total} events exist — a kind is silently absent",
f.name
);
// 5. Each fixture drives its declared buckets non-zero.
for &b in f.expect {
assert!(
b.count(t) > 0,
"fixture `{}` was tagged to populate `{}` but its count is 0",
f.name,
b.label()
);
}
// 5b. The unusual-outcome buckets are an exact whitelist: a fixture may
// only land events in a loss/deferral bucket it declared. This closes
// the under-emission gap — an event that should decompose but silently
// slips into `ungriddable`/`skipped` lights up an undeclared bucket
// here, instead of hiding (step 4 stays balanced because the event
// just moved buckets, and the success count merely shrinks).
for &b in UNUSUAL_BUCKETS {
if !f.expect.contains(&b) {
assert_eq!(
b.count(t),
0,
"fixture `{}` landed {} event(s) in `{}` without declaring it in \
`expect` — an event slipped into a loss/deferral bucket it should not",
f.name,
b.count(t),
b.label()
);
}
}
// 6. Aggregate.
for entry in totals.iter_mut() {
entry.1 += entry.0.count(t);
}
fixtures.push(FixtureReport {
name: f.name,
tier: f.tier,
annotations: ann,
});
}
CorpusReport { fixtures, totals }
}
/// The F3 acceptance assertion: every taxonomy bucket is non-empty across the
/// corpus (so "ineligible" is explicit and counted), or explicitly listed in
/// [`DEFERRED_BUCKETS`] with a reason. Emits per-kind counts to stderr.
pub fn assert_taxonomy_coverage() {
let report = classify_corpus();
assert!(
report.fixtures.len() >= 20,
"the representative corpus must have ≥20 fixtures (has {})",
report.fixtures.len()
);
eprintln!("[F3] corpus taxonomy ({} fixtures):", report.fixtures.len());
for (b, n) in &report.totals {
eprintln!(" {:<34} {n}", b.label());
}
let deferred: BTreeSet<Bucket> = DEFERRED_BUCKETS.iter().copied().collect();
let mut missing = Vec::new();
for b in Bucket::all() {
if report.total(b) == 0 && !deferred.contains(&b) {
missing.push(b.label());
}
}
assert!(
missing.is_empty(),
"taxonomy buckets never exercised by the corpus (and not deferred): {missing:?}"
);
// Each *broad* success bucket must be populated by several fixtures, not
// carried by a single one: "non-empty corpus-wide" alone would stay green if a
// bucket regressed everywhere except its one dedicated fixture. (The
// unusual-outcome buckets are intentionally single-fixture and are pinned
// exactly, per fixture, by `classify_corpus` step 5b instead.)
const BROAD: &[Bucket] = &[
Bucket::PitchedEvents,
Bucket::SpellingsInferred,
Bucket::DecompositionsInferred,
];
for &b in BROAD {
let populating = report
.fixtures
.iter()
.filter(|f| b.count(&f.annotations.taxonomy) > 0)
.count();
assert!(
populating >= 3,
"broad bucket `{}` is populated by only {populating} fixture(s); a \
success bucket carried by so few is fragile — a regression elsewhere \
would not register in corpus coverage",
b.label()
);
}
}
/// Runs the whole F3 harness (the conformance-suite entry point).
pub fn run_all() {
assert_taxonomy_coverage();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_fixture_is_invariant_clean() {
for f in corpus() {
let s = (f.build)();
let v = check_invariants(&s);
assert!(
v.is_empty(),
"fixture `{}` not invariant-clean: {v:?}",
f.name
);
}
}
#[test]
fn corpus_has_at_least_twenty_fixtures_across_three_tiers() {
let c = corpus();
assert!(c.len() >= 20, "corpus too small: {}", c.len());
for tier in [Tier::Common, Tier::Edge, Tier::Torture] {
assert!(
c.iter().any(|f| f.tier == tier),
"no fixtures in tier {tier:?}"
);
}
}
#[test]
fn taxonomy_coverage_is_complete() {
assert_taxonomy_coverage();
}
#[test]
fn fixture_names_are_unique() {
let mut seen = BTreeSet::new();
for f in corpus() {
assert!(seen.insert(f.name), "duplicate fixture name `{}`", f.name);
}
}
}