Skip to main content

sim_lib_music_counterpoint/
stretto.rs

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
15/// Builds a bounded compatibility graph, maximal cliques, and cluster chains.
16pub 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
101/// Materializes one transform request through `sim-lib-music-transform`.
102pub 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
138/// Fuses entries into a counterpoint view without claiming generated material.
139pub 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
170/// Returns the largest normalized suffix/prefix overlap between two clusters.
171///
172/// Returning the largest relation deliberately corrects the cataloged legacy
173/// behavior, which stopped at the first (smallest) match.
174pub 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}