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}