1419 lines
47 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|