1use std::collections::{BTreeMap, BTreeSet};
2
3use sim_lib_discrete_graph::{Directedness, Graph, connected_components};
4use sim_lib_music_core::{
5 AtomRef, Counterpoint, Melody, MelodyItem, Music, MusicObject, Rest, Time, TimedAtom,
6};
7use sim_lib_music_transform::{augment, pitch_invert, retrograde, retrograde_invert, transpose};
8
9use crate::{
10 ContrapuntalForm, NoteEvidence, OverlapEvidence, StrettoChain, StrettoCluster,
11 StrettoCompatibility, StrettoCouple, StrettoEntry, StrettoError, StrettoFusion, StrettoGraph,
12 StrettoPolicy, StrettoRejection, StrettoTransform, TimeSpan, Violation, analyze_counterpoint,
13};
14
15pub fn stretto_graph(
17 subject: &Melody,
18 policy: StrettoPolicy,
19) -> Result<StrettoGraph, StrettoError> {
20 validate_policy(&policy)?;
21 let entries = candidate_entries(subject, &policy)?;
22 let mut compatibility = Graph::with_nodes(entries, Directedness::Undirected);
23 let mut couples = Vec::new();
24 let mut rejections = Vec::new();
25 for first in 0..compatibility.node_count() {
26 for second in first + 1..compatibility.node_count() {
27 let pair = pairwise(
28 &compatibility.nodes[first],
29 &compatibility.nodes[second],
30 &policy,
31 )?;
32 if pair.compatible {
33 let weight = StrettoCompatibility {
34 overlap: pair.overlap,
35 violation_count: 0,
36 };
37 let edge_id = compatibility.add_edge(first, second, weight.clone())?;
38 debug_assert_eq!(edge_id, couples.len());
39 couples.push(StrettoCouple {
40 leader: first,
41 follower: second,
42 compatibility: weight,
43 });
44 } else {
45 rejections.push(StrettoRejection {
46 first,
47 second,
48 overlap: pair.overlap,
49 violations: pair.violations,
50 });
51 }
52 }
53 }
54 let components = connected_components(&compatibility)?;
55 let clique_nodes = maximal_cliques(
56 &compatibility,
57 policy.minimum_cluster_voices,
58 policy.max_clusters,
59 )?;
60 let mut clusters = Vec::with_capacity(clique_nodes.len());
61 for nodes in clique_nodes {
62 let edge_ids = clique_edge_ids(&compatibility, &nodes);
63 let cluster_entries = nodes
64 .iter()
65 .map(|index| compatibility.nodes[*index].clone())
66 .collect::<Vec<_>>();
67 clusters.push(StrettoCluster {
68 entries: nodes,
69 edge_ids,
70 fusion: fuse_stretto_entries(&cluster_entries)?,
71 });
72 }
73 let chain_graph = cluster_graph(&clusters, &compatibility.nodes)?;
74 let chains = longest_chains(
75 &chain_graph,
76 &clusters,
77 policy.max_chain_length,
78 policy.max_clusters,
79 )?;
80 Ok(StrettoGraph {
81 compatibility,
82 couples,
83 rejections,
84 components,
85 clusters,
86 chain_graph,
87 chains,
88 provenance: vec![
89 "mode=derived-stretto-analysis".to_owned(),
90 "generation=false".to_owned(),
91 "graph=sim-lib-discrete-graph/Graph".to_owned(),
92 "components=sim-lib-discrete-graph/connected_components".to_owned(),
93 "transforms=sim-lib-music-transform".to_owned(),
94 "pairwise-rules=analyze_counterpoint".to_owned(),
95 "cliques=maximal-pairwise-compatible".to_owned(),
96 "chain-overlap=largest-normalized-suffix-prefix".to_owned(),
97 ],
98 })
99}
100
101pub fn materialize_transform(
103 subject: &Melody,
104 request: &StrettoTransform,
105) -> Result<Melody, StrettoError> {
106 if request.duration_factor <= Time::from_integer(0) {
107 return Err(StrettoError::InvalidPolicy(
108 "transform duration factor must be positive".to_owned(),
109 ));
110 }
111 if request.form == ContrapuntalForm::Original
112 && request.transposition == 0
113 && request.duration_factor == Time::from_integer(1)
114 {
115 return Ok(subject.clone());
116 }
117 let source = Music::Melody(subject.clone());
118 let formed = match request.form {
119 ContrapuntalForm::Original => source,
120 ContrapuntalForm::Retrograde => retrograde(&source)?,
121 ContrapuntalForm::Inversion { axis } => pitch_invert(&source, axis)?,
122 ContrapuntalForm::RetrogradeInversion { axis } => retrograde_invert(&source, axis)?,
123 };
124 let timed = if request.duration_factor == Time::from_integer(1) {
125 formed
126 } else {
127 augment(&formed, request.duration_factor)?
128 };
129 let pitched = if request.transposition == 0 {
130 timed
131 } else {
132 transpose(&timed, request.transposition)?
133 };
134 let duration = subject.total_duration() * request.duration_factor;
135 melody_from_music(&pitched, duration)
136}
137
138pub fn fuse_stretto_entries(entries: &[StrettoEntry]) -> Result<StrettoFusion, StrettoError> {
140 let mut ordered = entries.to_vec();
141 ordered.sort_by(|left, right| left.delay.cmp(&right.delay).then(left.id.cmp(&right.id)));
142 let voices = ordered
143 .iter()
144 .map(|entry| delayed_melody(&entry.melody, entry.delay))
145 .collect::<Result<Vec<_>, _>>()?;
146 let names = ordered
147 .iter()
148 .map(|entry| format!("Stretto entry {}", entry.id))
149 .collect::<Vec<_>>();
150 Ok(StrettoFusion {
151 counterpoint: Counterpoint::new(voices, names)?,
152 entry_ids: ordered.iter().map(|entry| entry.id).collect(),
153 mode: "derived-analysis-not-generation".to_owned(),
154 provenance: ordered
155 .iter()
156 .map(|entry| {
157 format!(
158 "entry={}; delay={}; form={:?}; transpose={}; duration-factor={}",
159 entry.id,
160 rational(entry.delay),
161 entry.transform.form,
162 entry.transform.transposition,
163 rational(entry.transform.duration_factor)
164 )
165 })
166 .collect(),
167 })
168}
169
170pub fn cluster_overlap(
175 left: &StrettoCluster,
176 right: &StrettoCluster,
177 entries: &[StrettoEntry],
178) -> usize {
179 let left = normalized_signature(&left.entries, entries);
180 let right = normalized_signature(&right.entries, entries);
181 let limit = left.len().min(right.len());
182 (1..=limit)
183 .rev()
184 .find(|size| {
185 normalize_slice(&left[left.len() - size..]) == normalize_slice(&right[..*size])
186 })
187 .unwrap_or(0)
188}
189
190struct Pairwise {
191 compatible: bool,
192 overlap: OverlapEvidence,
193 violations: Vec<Violation>,
194}
195
196fn validate_policy(policy: &StrettoPolicy) -> Result<(), StrettoError> {
197 if policy.minimum_overlap <= Time::from_integer(0) {
198 return Err(StrettoError::InvalidPolicy(
199 "minimum overlap must be positive".to_owned(),
200 ));
201 }
202 if policy
203 .delays
204 .iter()
205 .any(|delay| *delay < Time::from_integer(0))
206 {
207 return Err(StrettoError::InvalidPolicy(
208 "entry delays cannot be negative".to_owned(),
209 ));
210 }
211 if policy.transforms.is_empty() {
212 return Err(StrettoError::InvalidPolicy(
213 "at least one transform is required".to_owned(),
214 ));
215 }
216 if policy
217 .transforms
218 .iter()
219 .any(|transform| transform.duration_factor <= Time::from_integer(0))
220 {
221 return Err(StrettoError::InvalidPolicy(
222 "transform duration factors must be positive".to_owned(),
223 ));
224 }
225 if policy.max_entries == 0
226 || policy.minimum_cluster_voices < 2
227 || policy.max_clusters == 0
228 || policy.max_chain_length < 2
229 {
230 return Err(StrettoError::InvalidPolicy(
231 "entry/cluster/chain bounds must be non-zero and structurally usable".to_owned(),
232 ));
233 }
234 policy
235 .compatibility_rules
236 .validate()
237 .map_err(|error| StrettoError::InvalidPolicy(error.to_string()))
238}
239
240fn candidate_entries(
241 subject: &Melody,
242 policy: &StrettoPolicy,
243) -> Result<Vec<StrettoEntry>, StrettoError> {
244 let mut entries = vec![StrettoEntry {
245 id: 0,
246 delay: Time::from_integer(0),
247 transform: StrettoTransform::original(0),
248 melody: subject.clone(),
249 }];
250 let mut seen = BTreeSet::from([(
251 Time::from_integer(0),
252 transform_key(&StrettoTransform::original(0)),
253 )]);
254 'delays: for delay in &policy.delays {
255 for transform in &policy.transforms {
256 if entries.len() >= policy.max_entries {
257 break 'delays;
258 }
259 let key = (*delay, transform_key(transform));
260 if !seen.insert(key) {
261 continue;
262 }
263 let melody = materialize_transform(subject, transform)?;
264 let overlap = subject
265 .total_duration()
266 .min(*delay + melody.total_duration())
267 - *delay;
268 if overlap < policy.minimum_overlap {
269 continue;
270 }
271 entries.push(StrettoEntry {
272 id: entries.len(),
273 delay: *delay,
274 transform: transform.clone(),
275 melody,
276 });
277 }
278 }
279 Ok(entries)
280}
281
282fn pairwise(
283 first: &StrettoEntry,
284 second: &StrettoEntry,
285 policy: &StrettoPolicy,
286) -> Result<Pairwise, StrettoError> {
287 let start = first.delay.max(second.delay);
288 let end = (first.delay + first.melody.total_duration())
289 .min(second.delay + second.melody.total_duration());
290 let admitted_end = end.max(start);
291 let minimum_met = end - start >= policy.minimum_overlap;
292 let base = first.delay.min(second.delay);
293 let cp = Counterpoint::new(
294 vec![
295 delayed_melody(&first.melody, first.delay - base)?,
296 delayed_melody(&second.melody, second.delay - base)?,
297 ],
298 vec![
299 format!("Entry {}", first.id),
300 format!("Entry {}", second.id),
301 ],
302 )?;
303 let report = analyze_counterpoint(&cp, &policy.compatibility_rules);
304 let relative_overlap = TimeSpan::new(start - base, admitted_end - base);
305 let violations = report
306 .violations
307 .into_iter()
308 .filter(|violation| intersects(&violation.span, &relative_overlap))
309 .map(|violation| shift_violation(violation, base))
310 .collect::<Vec<_>>();
311 let simultaneous = report
312 .alignment
313 .iter()
314 .filter(|window| window.notes.len() >= 2 && intersects(&window.span, &relative_overlap))
315 .collect::<Vec<_>>();
316 let mut histogram = BTreeMap::<u8, usize>::new();
317 for window in &simultaneous {
318 let first = window.notes[0].pitch.semitone();
319 let second = window.notes[1].pitch.semitone();
320 let distance = (second - first).unsigned_abs() as u8 % 12;
321 let class = distance.min(12 - distance);
322 *histogram.entry(class).or_default() += 1;
323 }
324 let overlap = OverlapEvidence {
325 span: TimeSpan::new(start, admitted_end),
326 simultaneous_windows: simultaneous.len(),
327 interval_classes: histogram.into_iter().collect(),
328 facts: vec![
329 format!("entries={},{}", first.id, second.id),
330 format!("minimum-overlap={}", rational(policy.minimum_overlap)),
331 format!("minimum-overlap-met={minimum_met}"),
332 format!("rule-set={}", policy.compatibility_rules.id),
333 "alignment=exact-rational-half-open".to_owned(),
334 ],
335 };
336 Ok(Pairwise {
337 compatible: minimum_met && violations.is_empty(),
338 overlap,
339 violations,
340 })
341}
342
343fn melody_from_music(music: &Music, duration: Time) -> Result<Melody, StrettoError> {
344 let mut atoms = Vec::<TimedAtom<'_>>::new();
345 music.voices(Time::from_integer(0), &mut atoms);
346 let mut notes = atoms
347 .into_iter()
348 .filter_map(|atom| match atom.atom {
349 AtomRef::Note(note) => Some((atom.onset, note)),
350 AtomRef::Rest(_) | AtomRef::Phantom(_) => None,
351 })
352 .collect::<Vec<_>>();
353 notes.sort_by(|left, right| {
354 left.0
355 .cmp(&right.0)
356 .then_with(|| left.1.pitch.cmp(&right.1.pitch))
357 });
358 let mut cursor = Time::from_integer(0);
359 let mut items = Vec::new();
360 for (onset, note) in notes {
361 if onset < cursor {
362 return Err(StrettoError::NonMonophonic(format!(
363 "note at {} overlaps prior release {}",
364 rational(onset),
365 rational(cursor)
366 )));
367 }
368 if onset > cursor {
369 items.push(MelodyItem::Rest(Rest::new(onset - cursor)?));
370 }
371 cursor = onset + note.duration;
372 items.push(MelodyItem::Note(note));
373 }
374 if duration > cursor {
375 items.push(MelodyItem::Rest(Rest::new(duration - cursor)?));
376 }
377 Ok(Melody::new(items)?)
378}
379
380fn delayed_melody(melody: &Melody, delay: Time) -> Result<Melody, StrettoError> {
381 let mut items =
382 Vec::with_capacity(melody.items.len() + usize::from(delay > Time::from_integer(0)));
383 if delay > Time::from_integer(0) {
384 items.push(MelodyItem::Rest(Rest::new(delay)?));
385 }
386 items.extend(melody.items.clone());
387 Ok(Melody::new(items)?)
388}
389
390fn maximal_cliques<N, W>(
391 graph: &Graph<N, W>,
392 minimum: usize,
393 limit: usize,
394) -> Result<Vec<Vec<usize>>, StrettoError> {
395 let adjacency = (0..graph.node_count())
396 .map(|node| {
397 Ok(graph
398 .neighbors(node)?
399 .into_iter()
400 .map(|neighbor| neighbor.node)
401 .collect::<BTreeSet<_>>())
402 })
403 .collect::<Result<Vec<_>, sim_lib_discrete_graph::GraphError>>()?;
404 let mut output = Vec::new();
405 bron_kerbosch(
406 Vec::new(),
407 (0..graph.node_count()).collect(),
408 BTreeSet::new(),
409 &adjacency,
410 minimum,
411 limit,
412 &mut output,
413 );
414 output.sort();
415 Ok(output)
416}
417
418fn bron_kerbosch(
419 selected: Vec<usize>,
420 mut candidates: BTreeSet<usize>,
421 mut excluded: BTreeSet<usize>,
422 adjacency: &[BTreeSet<usize>],
423 minimum: usize,
424 limit: usize,
425 output: &mut Vec<Vec<usize>>,
426) {
427 if output.len() >= limit {
428 return;
429 }
430 if candidates.is_empty() && excluded.is_empty() {
431 if selected.len() >= minimum {
432 output.push(selected);
433 }
434 return;
435 }
436 let ordered = candidates.iter().copied().collect::<Vec<_>>();
437 for node in ordered {
438 if output.len() >= limit {
439 break;
440 }
441 let mut next = selected.clone();
442 next.push(node);
443 bron_kerbosch(
444 next,
445 candidates.intersection(&adjacency[node]).copied().collect(),
446 excluded.intersection(&adjacency[node]).copied().collect(),
447 adjacency,
448 minimum,
449 limit,
450 output,
451 );
452 candidates.remove(&node);
453 excluded.insert(node);
454 }
455}
456
457fn clique_edge_ids<N, W>(graph: &Graph<N, W>, nodes: &[usize]) -> Vec<usize> {
458 let members = nodes.iter().copied().collect::<BTreeSet<_>>();
459 graph
460 .edges
461 .iter()
462 .filter(|edge| members.contains(&edge.source) && members.contains(&edge.target))
463 .map(|edge| edge.id)
464 .collect()
465}
466
467fn cluster_graph(
468 clusters: &[StrettoCluster],
469 entries: &[StrettoEntry],
470) -> Result<Graph<usize, usize>, StrettoError> {
471 let mut graph = Graph::with_nodes((0..clusters.len()).collect(), Directedness::Directed);
472 for first in 0..clusters.len() {
473 for second in 0..clusters.len() {
474 if first == second {
475 continue;
476 }
477 let overlap = cluster_overlap(&clusters[first], &clusters[second], entries);
478 if overlap > 0 {
479 graph.add_edge(first, second, overlap)?;
480 }
481 }
482 }
483 Ok(graph)
484}
485
486fn longest_chains(
487 graph: &Graph<usize, usize>,
488 clusters: &[StrettoCluster],
489 max_length: usize,
490 max_results: usize,
491) -> Result<Vec<StrettoChain>, StrettoError> {
492 let mut paths = Vec::new();
493 for start in 0..graph.node_count() {
494 extend_chain(
495 graph,
496 vec![start],
497 Vec::new(),
498 max_length,
499 max_results,
500 &mut paths,
501 )?;
502 if paths.len() >= max_results {
503 break;
504 }
505 }
506 paths.retain(|(path, _)| path.len() >= 2);
507 let longest = paths.iter().map(|(path, _)| path.len()).max().unwrap_or(0);
508 paths.retain(|(path, _)| path.len() == longest);
509 paths.sort();
510 paths.dedup();
511 Ok(paths
512 .into_iter()
513 .map(|(path, overlaps)| {
514 let mut fused = clusters[path[0]].entries.clone();
515 for (position, cluster_index) in path.iter().enumerate().skip(1) {
516 let overlap = overlaps[position - 1].min(fused.len());
517 fused.truncate(fused.len() - overlap);
518 fused.extend(clusters[*cluster_index].entries.iter().copied());
519 }
520 StrettoChain {
521 clusters: path,
522 overlaps,
523 fused_entries: fused,
524 }
525 })
526 .collect())
527}
528
529fn extend_chain(
530 graph: &Graph<usize, usize>,
531 path: Vec<usize>,
532 overlaps: Vec<usize>,
533 max_length: usize,
534 max_results: usize,
535 output: &mut Vec<(Vec<usize>, Vec<usize>)>,
536) -> Result<(), StrettoError> {
537 if output.len() >= max_results {
538 return Ok(());
539 }
540 let current = *path.last().expect("chain paths are non-empty");
541 let extensions = graph
542 .neighbors(current)?
543 .into_iter()
544 .filter(|neighbor| !path.contains(&neighbor.node))
545 .map(|neighbor| (neighbor.node, *neighbor.weight))
546 .collect::<Vec<_>>();
547 if extensions.is_empty() || path.len() >= max_length {
548 output.push((path, overlaps));
549 return Ok(());
550 }
551 for (next, overlap) in extensions {
552 let mut next_path = path.clone();
553 next_path.push(next);
554 let mut next_overlaps = overlaps.clone();
555 next_overlaps.push(overlap);
556 extend_chain(
557 graph,
558 next_path,
559 next_overlaps,
560 max_length,
561 max_results,
562 output,
563 )?;
564 }
565 Ok(())
566}
567
568#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
569struct EntrySignature {
570 delay: Time,
571 transposition: i32,
572 form: String,
573 duration_factor: Time,
574}
575
576fn normalized_signature(indices: &[usize], entries: &[StrettoEntry]) -> Vec<EntrySignature> {
577 let mut signature = indices
578 .iter()
579 .map(|index| {
580 let entry = &entries[*index];
581 EntrySignature {
582 delay: entry.delay,
583 transposition: entry.transform.transposition,
584 form: form_key(entry.transform.form),
585 duration_factor: entry.transform.duration_factor,
586 }
587 })
588 .collect::<Vec<_>>();
589 signature.sort();
590 normalize_slice(&signature)
591}
592
593fn normalize_slice(signature: &[EntrySignature]) -> Vec<EntrySignature> {
594 let Some(anchor) = signature.first() else {
595 return Vec::new();
596 };
597 signature
598 .iter()
599 .map(|entry| EntrySignature {
600 delay: entry.delay - anchor.delay,
601 transposition: (entry.transposition - anchor.transposition).rem_euclid(12),
602 form: entry.form.clone(),
603 duration_factor: entry.duration_factor,
604 })
605 .collect()
606}
607
608fn transform_key(transform: &StrettoTransform) -> (String, i32, Time) {
609 (
610 form_key(transform.form),
611 transform.transposition,
612 transform.duration_factor,
613 )
614}
615
616fn form_key(form: ContrapuntalForm) -> String {
617 match form {
618 ContrapuntalForm::Original => "original".to_owned(),
619 ContrapuntalForm::Retrograde => "retrograde".to_owned(),
620 ContrapuntalForm::Inversion { axis } => {
621 format!("inversion@{}", axis.semitone())
622 }
623 ContrapuntalForm::RetrogradeInversion { axis } => {
624 format!("retrograde-inversion@{}", axis.semitone())
625 }
626 }
627}
628
629fn shift_violation(mut violation: Violation, offset: Time) -> Violation {
630 violation.span.start += offset;
631 violation.span.end += offset;
632 for note in &mut violation.notes {
633 shift_note(note, offset);
634 }
635 violation
636}
637
638fn shift_note(note: &mut NoteEvidence, offset: Time) {
639 note.span.start += offset;
640 note.span.end += offset;
641}
642
643fn intersects(left: &TimeSpan, right: &TimeSpan) -> bool {
644 left.start < right.end && right.start < left.end
645}
646
647fn rational(value: Time) -> String {
648 format!("{}/{}", value.numer(), value.denom())
649}