Skip to main content

sim_lib_music_counterpoint/
model.rs

1use sim_lib_discrete_graph::Graph;
2use sim_lib_discrete_search::{SearchReceipt, SearchStatus};
3use sim_lib_music_consonance::{ConsonancePatch, PatchError};
4use sim_lib_music_core::{
5    ConversionError, Counterpoint, Melody, MusicError, ObjectId, Pitch, Staff, Time,
6};
7use thiserror::Error;
8
9use crate::RuleSet;
10
11/// Exact half-open span `[start, end)` in whole-note units.
12#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
13pub struct TimeSpan {
14    /// Inclusive start.
15    pub start: Time,
16    /// Exclusive end.
17    pub end: Time,
18}
19
20impl TimeSpan {
21    /// Creates a span. Callers only construct spans from validated music.
22    pub fn new(start: Time, end: Time) -> Self {
23        Self { start, end }
24    }
25
26    /// Returns the exact span duration.
27    pub fn duration(&self) -> Time {
28        self.end - self.start
29    }
30}
31
32/// Stable evidence identifying one analyzed voice.
33#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub struct VoiceEvidence {
35    /// Zero-based position in the source counterpoint.
36    pub index: usize,
37    /// Source-derived stable voice identity.
38    pub id: ObjectId,
39    /// Human-readable source voice name.
40    pub name: String,
41}
42
43/// Stable evidence identifying one analyzed note and its exact lifetime.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct NoteEvidence {
46    /// Voice containing the note.
47    pub voice: VoiceEvidence,
48    /// Zero-based note position within the voice.
49    pub index: usize,
50    /// Stable logical note identity.
51    pub note_id: ObjectId,
52    /// Stable score-event identity.
53    pub event_id: ObjectId,
54    /// Exact note lifetime.
55    pub span: TimeSpan,
56    /// Octave-aware source pitch.
57    pub pitch: Pitch,
58}
59
60/// A maximal exact interval with an unchanging set of sounding notes.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct AlignmentWindow {
63    /// Exact half-open window.
64    pub span: TimeSpan,
65    /// Notes sounding throughout the window, in deterministic voice order.
66    pub notes: Vec<NoteEvidence>,
67}
68
69/// Direction of one voice between adjacent aligned events.
70#[derive(Copy, Clone, Debug, PartialEq, Eq)]
71pub enum MotionDirection {
72    /// Pitch moved downward.
73    Down,
74    /// Pitch did not change.
75    Static,
76    /// Pitch moved upward.
77    Up,
78}
79
80/// Relative two-voice motion across adjacent exact alignment windows.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct Motion {
83    /// Pair of voices, in source order.
84    pub voices: [VoiceEvidence; 2],
85    /// Previous notes followed by current notes for both voices.
86    pub notes: [NoteEvidence; 4],
87    /// Exact span from the previous boundary through the current window.
88    pub span: TimeSpan,
89    /// Motion of the first voice.
90    pub first: MotionDirection,
91    /// Motion of the second voice.
92    pub second: MotionDirection,
93    /// Absolute semitone interval before the motion.
94    pub interval_before: i32,
95    /// Absolute semitone interval after the motion.
96    pub interval_after: i32,
97}
98
99/// Concrete metric evidence attached to a rule outcome.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct MetricEvidence {
102    /// Stable metric name.
103    pub metric: String,
104    /// Observed exact or integer value.
105    pub observed: String,
106    /// Declared limit or accepted set.
107    pub expected: String,
108    /// Unit or comparison domain.
109    pub unit: String,
110    /// Additional inspectable facts used by the decision.
111    pub facts: Vec<String>,
112}
113
114/// One failed rule with complete source and measurement evidence.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Violation {
117    /// Stable rule identifier.
118    pub rule: String,
119    /// Human-readable rule explanation.
120    pub message: String,
121    /// Every involved voice.
122    pub voices: Vec<VoiceEvidence>,
123    /// Every involved note.
124    pub notes: Vec<NoteEvidence>,
125    /// Exact affected span.
126    pub span: TimeSpan,
127    /// Measurement proving why the rule failed.
128    pub metric: MetricEvidence,
129}
130
131/// Provenance distinguishing inspection of source material from generation.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct AnalysisProvenance {
134    /// Stable analysis mode; always `"existing-counterpoint"`.
135    pub mode: String,
136    /// Rule-set identifier.
137    pub rule_set: String,
138    /// Exact conversion and alignment facts.
139    pub facts: Vec<String>,
140}
141
142/// Complete analysis of existing counterpoint.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct CounterpointReport {
145    /// Exact event-boundary alignment.
146    pub alignment: Vec<AlignmentWindow>,
147    /// All adjacent two-voice motions.
148    pub motions: Vec<Motion>,
149    /// Every rule violation, each with its own span and metric evidence.
150    pub violations: Vec<Violation>,
151    /// Source and policy provenance.
152    pub provenance: AnalysisProvenance,
153}
154
155impl CounterpointReport {
156    /// Returns `true` when no declared rule failed.
157    pub fn is_legal(&self) -> bool {
158        self.violations.is_empty()
159    }
160}
161
162/// Cadential pitch-domain restriction applied by the generator.
163#[derive(Copy, Clone, Debug, PartialEq, Eq)]
164pub enum CadencePolicy {
165    /// Do not add a cadence restriction beyond the rule set.
166    Open,
167    /// Require a perfect interval against the cantus at the final slot.
168    PerfectFinal,
169    /// Require perfect intervals against the cantus at both endpoints.
170    PerfectEndpoints,
171}
172
173/// Cross-result diversity policy applied in deterministic score order.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct DiversityPolicy {
176    /// Minimum number of pitch assignments that must differ from every retained result.
177    pub minimum_pitch_changes: usize,
178}
179
180impl Default for DiversityPolicy {
181    fn default() -> Self {
182        Self {
183            minimum_pitch_changes: 1,
184        }
185    }
186}
187
188/// Musical controls compiled into one finite counterpoint CSP.
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct CounterpointGenerationPolicy {
191    /// Number of new voices to add beside the fixed cantus.
192    pub voices: usize,
193    /// Endpoint policy compiled into pitch domains.
194    pub cadence: CadencePolicy,
195    /// Cross-result distinctness policy.
196    pub diversity: DiversityPolicy,
197    /// MIDI velocity assigned to generated notes.
198    pub velocity: u8,
199}
200
201impl Default for CounterpointGenerationPolicy {
202    fn default() -> Self {
203        Self {
204            voices: 1,
205            cadence: CadencePolicy::PerfectEndpoints,
206            diversity: DiversityPolicy::default(),
207            velocity: 96,
208        }
209    }
210}
211
212/// One generated-voice pitch variable at an exact rhythmic slot.
213#[derive(Clone, Debug, PartialEq, Eq)]
214pub struct CounterpointVariable {
215    /// Stable position in the compiled variable order.
216    pub index: usize,
217    /// Zero-based generated voice index.
218    pub voice: usize,
219    /// Zero-based exact rhythmic slot.
220    pub slot: usize,
221    /// Exact onset in whole-note units.
222    pub onset: Time,
223    /// Fixed exact note duration.
224    pub duration: Time,
225}
226
227/// Finite pitch domain for one counterpoint variable.
228#[derive(Clone, Debug, PartialEq, Eq)]
229pub struct CounterpointDomain {
230    /// Variable governed by this domain.
231    pub variable: CounterpointVariable,
232    /// Allowed MIDI pitches in canonical ascending order.
233    pub pitches: Vec<u8>,
234}
235
236/// Inspectable finite CSP compiled from a cantus, rules, and generation policy.
237#[derive(Clone, Debug, PartialEq, Eq)]
238pub struct CounterpointCsp {
239    /// Variables in exact-time then voice order.
240    pub variables: Vec<CounterpointVariable>,
241    /// One finite pitch domain per variable.
242    pub domains: Vec<CounterpointDomain>,
243    /// Fixed duration of every generated note.
244    pub rhythm: Time,
245    /// Rule-set id from which constraints were compiled.
246    pub rule_set: String,
247    /// Stable compilation and delegation evidence.
248    pub facts: Vec<String>,
249}
250
251impl CounterpointCsp {
252    /// Number of rhythmic slots per generated voice.
253    pub fn slots(&self) -> usize {
254        self.variables
255            .iter()
256            .map(|variable| variable.slot)
257            .max()
258            .map_or(0, |slot| slot + 1)
259    }
260}
261
262/// One legal generated counterpoint and its exact reversible addition.
263#[derive(Clone, Debug, PartialEq)]
264pub struct CounterpointGenerationResult {
265    /// Fixed cantus followed by generated voices.
266    pub counterpoint: Counterpoint,
267    /// Identity-bearing staff after applying `patch` to the cantus staff.
268    pub completed: Staff,
269    /// Content-bound strictly additive patch for all generated voices.
270    pub patch: ConsonancePatch,
271    /// Analyzer proof under the same rule set; it contains no violations.
272    pub analysis: CounterpointReport,
273    /// Deterministic non-negative soft cost.
274    pub score: i64,
275    /// Stable pitch-assignment fingerprint.
276    pub fingerprint: String,
277}
278
279/// Counterpoint-specific interpretation of one generic search receipt.
280#[derive(Clone, Debug, PartialEq, Eq)]
281pub struct CounterpointGenerationReceipt {
282    /// Unmodified generic bounded-search receipt.
283    pub search: SearchReceipt,
284    /// Legal assignments emitted by the search before diversity selection.
285    pub raw_result_count: usize,
286    /// Results retained after diversity selection.
287    pub selected_result_count: usize,
288    /// Legal assignments rejected only by the diversity policy.
289    pub diversity_rejected: usize,
290    /// Stable materialization and policy evidence.
291    pub facts: Vec<String>,
292}
293
294impl CounterpointGenerationReceipt {
295    /// Final generic termination status.
296    pub fn status(&self) -> &SearchStatus {
297        &self.search.status
298    }
299}
300
301/// Complete generated result set, compiled CSP, and termination receipt.
302#[derive(Clone, Debug, PartialEq)]
303pub struct CounterpointGeneration {
304    /// Inspectable variables, domains, rhythm, and compilation facts.
305    pub csp: CounterpointCsp,
306    /// Legal diverse results in deterministic score order.
307    pub results: Vec<CounterpointGenerationResult>,
308    /// Honest bounds, cancellation, search, and diversity evidence.
309    pub receipt: CounterpointGenerationReceipt,
310}
311
312/// Failure to validate, compile, or materialize counterpoint generation.
313#[derive(Debug, Error)]
314pub enum GenerationError {
315    /// Caller policy cannot define a finite valid generation problem.
316    #[error("invalid counterpoint generation policy: {0}")]
317    InvalidPolicy(String),
318    /// Counterpoint rule data is invalid.
319    #[error(transparent)]
320    Rules(#[from] crate::RuleError),
321    /// A music value could not be built.
322    #[error(transparent)]
323    Music(#[from] MusicError),
324    /// Canonical score conversion failed.
325    #[error(transparent)]
326    Conversion(#[from] ConversionError),
327    /// Reversible patch construction or validation failed.
328    #[error(transparent)]
329    Patch(#[from] PatchError),
330    /// Internal search/materialization agreement was violated.
331    #[error("counterpoint generation invariant failed: {0}")]
332    Invariant(String),
333}
334
335/// Result of fusing analyzed stretto entries into a viewable counterpoint.
336#[derive(Clone, Debug, PartialEq, Eq)]
337pub struct StrettoFusion {
338    /// Materialized voices ordered by onset and stable entry id.
339    pub counterpoint: Counterpoint,
340    /// Entry ids represented by the fused value.
341    pub entry_ids: Vec<usize>,
342    /// Explicit statement that this is a derived analysis view.
343    pub mode: String,
344    /// Transform-owner provenance for every materialized entry.
345    pub provenance: Vec<String>,
346}
347
348/// Contrapuntal form delegated to the music transform owner.
349#[derive(Copy, Clone, Debug, PartialEq, Eq)]
350pub enum ContrapuntalForm {
351    /// Preserve pitch and time order.
352    Original,
353    /// Reverse exact note placement in time.
354    Retrograde,
355    /// Invert pitch around the supplied axis.
356    Inversion {
357        /// Pitch inversion axis.
358        axis: Pitch,
359    },
360    /// Apply pitch inversion followed by retrograde.
361    RetrogradeInversion {
362        /// Pitch inversion axis.
363        axis: Pitch,
364    },
365}
366
367/// One reusable transform request for a stretto entry.
368#[derive(Clone, Debug, PartialEq, Eq)]
369pub struct StrettoTransform {
370    /// Contrapuntal pitch/time form.
371    pub form: ContrapuntalForm,
372    /// Chromatic transposition applied after the form.
373    pub transposition: i32,
374    /// Positive exact duration factor.
375    pub duration_factor: Time,
376}
377
378impl StrettoTransform {
379    /// Original form at one chromatic transposition.
380    pub fn original(transposition: i32) -> Self {
381        Self {
382            form: ContrapuntalForm::Original,
383            transposition,
384            duration_factor: Time::from_integer(1),
385        }
386    }
387}
388
389/// Bounded policy for deriving and comparing stretto entries.
390#[derive(Clone, Debug, PartialEq, Eq)]
391pub struct StrettoPolicy {
392    /// Exact follower delays; the anchor at zero is supplied automatically.
393    pub delays: Vec<Time>,
394    /// Transform requests crossed with every admitted delay.
395    pub transforms: Vec<StrettoTransform>,
396    /// Minimum temporal intersection for any compatible pair.
397    pub minimum_overlap: Time,
398    /// Counterpoint rules used for every pairwise compatibility decision.
399    pub compatibility_rules: RuleSet,
400    /// Maximum graph nodes, including the anchor.
401    pub max_entries: usize,
402    /// Minimum voices in a reported maximal clique.
403    pub minimum_cluster_voices: usize,
404    /// Maximum reported maximal cliques.
405    pub max_clusters: usize,
406    /// Maximum clusters in one reported simple chain.
407    pub max_chain_length: usize,
408}
409
410impl Default for StrettoPolicy {
411    fn default() -> Self {
412        let mut compatibility_rules = RuleSet::open();
413        compatibility_rules.id = "stretto-default".to_owned();
414        compatibility_rules.intervals.consonant_harmonic_classes = vec![0, 3, 4, 5];
415        Self {
416            delays: vec![Time::new(1, 4), Time::new(1, 2), Time::new(3, 4)],
417            transforms: (0..12).map(StrettoTransform::original).collect(),
418            minimum_overlap: Time::new(1, 4),
419            compatibility_rules,
420            max_entries: 64,
421            minimum_cluster_voices: 3,
422            max_clusters: 128,
423            max_chain_length: 8,
424        }
425    }
426}
427
428/// One materialized graph node derived from a subject.
429#[derive(Clone, Debug, PartialEq, Eq)]
430pub struct StrettoEntry {
431    /// Stable graph-local id.
432    pub id: usize,
433    /// Exact onset relative to the anchor.
434    pub delay: Time,
435    /// Transform request and provenance.
436    pub transform: StrettoTransform,
437    /// Materialized melody returned through transform-owner operations.
438    pub melody: Melody,
439}
440
441/// Exact overlap facts for one entry pair.
442#[derive(Clone, Debug, PartialEq, Eq)]
443pub struct OverlapEvidence {
444    /// Temporal intersection of both entry extents.
445    pub span: TimeSpan,
446    /// Exact alignment windows in which both voices sound.
447    pub simultaneous_windows: usize,
448    /// Histogram of observed harmonic interval classes.
449    pub interval_classes: Vec<(u8, usize)>,
450    /// Facts naming the policy and pairwise analysis path.
451    pub facts: Vec<String>,
452}
453
454/// Weight carried by a compatible edge in the shared graph value.
455#[derive(Clone, Debug, PartialEq, Eq)]
456pub struct StrettoCompatibility {
457    /// Exact pairwise overlap evidence.
458    pub overlap: OverlapEvidence,
459    /// Number of pairwise rule violations; always zero for a graph edge.
460    pub violation_count: usize,
461}
462
463/// One compatible pair represented by a compatibility-graph edge.
464#[derive(Clone, Debug, PartialEq, Eq)]
465pub struct StrettoCouple {
466    /// First graph-node id.
467    pub leader: usize,
468    /// Second graph-node id.
469    pub follower: usize,
470    /// Exact pairwise evidence.
471    pub compatibility: StrettoCompatibility,
472}
473
474/// One rejected pair retained outside the compatibility graph.
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct StrettoRejection {
477    /// First graph-node id.
478    pub first: usize,
479    /// Second graph-node id.
480    pub second: usize,
481    /// Exact temporal and interval evidence.
482    pub overlap: OverlapEvidence,
483    /// Per-rule reasons for rejection.
484    pub violations: Vec<Violation>,
485}
486
487/// A maximal pairwise-compatible clique.
488#[derive(Clone, Debug, PartialEq, Eq)]
489pub struct StrettoCluster {
490    /// Graph-node ids in deterministic order.
491    pub entries: Vec<usize>,
492    /// Compatibility edge ids proving every pair.
493    pub edge_ids: Vec<usize>,
494    /// Fused analysis view.
495    pub fusion: StrettoFusion,
496}
497
498/// A sequence of clusters joined by normalized suffix/prefix overlap.
499#[derive(Clone, Debug, PartialEq, Eq)]
500pub struct StrettoChain {
501    /// Cluster indices in traversal order.
502    pub clusters: Vec<usize>,
503    /// Entry overlap at each adjacent join.
504    pub overlaps: Vec<usize>,
505    /// Normalized entry ids after splicing the chain.
506    pub fused_entries: Vec<usize>,
507}
508
509/// Complete bounded stretto compatibility result.
510#[derive(Clone, Debug, PartialEq)]
511pub struct StrettoGraph {
512    /// Shared undirected graph whose node labels are materialized entries.
513    pub compatibility: Graph<StrettoEntry, StrettoCompatibility>,
514    /// Every compatible graph edge as a named couple.
515    pub couples: Vec<StrettoCouple>,
516    /// Rejected candidate pairs with rule evidence.
517    pub rejections: Vec<StrettoRejection>,
518    /// Coarse connected components from the shared graph owner.
519    pub components: Vec<Vec<usize>>,
520    /// Maximal pairwise-compatible cliques.
521    pub clusters: Vec<StrettoCluster>,
522    /// Directed cluster-overlap graph; edge weights are overlap lengths.
523    pub chain_graph: Graph<usize, usize>,
524    /// Longest bounded simple cluster chains.
525    pub chains: Vec<StrettoChain>,
526    /// Explicit analysis provenance.
527    pub provenance: Vec<String>,
528}
529
530/// Failure to validate or materialize a stretto analysis.
531#[derive(Debug, Error)]
532pub enum StrettoError {
533    /// Policy contains an invalid bound or exact time.
534    #[error("invalid stretto policy: {0}")]
535    InvalidPolicy(String),
536    /// A delegated music transform failed.
537    #[error(transparent)]
538    Transform(#[from] sim_lib_music_transform::TransformError),
539    /// A transformed line could not be represented as monophonic melody.
540    #[error("transformed stretto entry is not monophonic: {0}")]
541    NonMonophonic(String),
542    /// A graph operation failed.
543    #[error(transparent)]
544    Graph(#[from] sim_lib_discrete_graph::GraphError),
545    /// Fused counterpoint construction failed.
546    #[error(transparent)]
547    Music(#[from] sim_lib_music_core::MusicError),
548}