Skip to main content

guitar_tab_generator/
arrangement.rs

1use crate::{
2    error::{TabError, UnplayablePitch},
3    guitar::{Guitar, PitchFingering, generate_pitch_fingerings},
4    pitch::Pitch,
5};
6use average::Mean;
7use itertools::Itertools;
8use memoize::memoize;
9use ordered_float::OrderedFloat;
10use pathfinding::prelude::yen;
11use std::{collections::HashSet, rc::Rc};
12
13/// One logical line of a parsed or arranged composition.
14///
15/// `Playable` holds the line's content (pitches during parsing, fingerings after
16/// arrangement). `Rest` is an empty or comment-only line. `MeasureBreak` is a bar
17/// line drawn in the rendered tab.
18#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
19pub enum Line<T> {
20    MeasureBreak,
21    Rest,
22    Playable(T),
23}
24use Line::{MeasureBreak, Playable, Rest};
25
26#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
27enum Node {
28    Start,
29    Rest {
30        line_index: u16,
31    },
32    Playable {
33        line_index: u16,
34        scored_beat_fingering: Rc<ScoredBeatFingering>,
35    },
36}
37
38/// One pitch's set of candidate `PitchFingering`s across the guitar's strings.
39pub(crate) type PitchVec<T> = Vec<T>;
40/// One beat's worth of items (usually `Pitch` or `PitchFingering`).
41pub type BeatVec<T> = Vec<T>;
42
43/// Index of the first `Playable` line in `lines`, or `0` if the sequence has none.
44///
45/// Both `generate_arrangements` and `create_arrangements` skip leading rests before
46/// shipping the input downstream, so the predicate lives in one place.
47pub(crate) fn first_playable_index<T>(lines: &[Line<T>]) -> usize {
48    lines
49        .iter()
50        .position(|line| matches!(line, Playable(_)))
51        .unwrap_or(0)
52}
53
54/// A single playable assignment of fingerings for one beat, with precomputed difficulty
55/// features (average non-zero fret, non-zero fret span).
56#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
57pub(crate) struct ScoredBeatFingering {
58    beat_fingering: BeatVec<PitchFingering>,
59    avg_non_zero_fret: Option<OrderedFloat<f64>>,
60    non_zero_fret_span: u8,
61}
62impl ScoredBeatFingering {
63    /// Builds a `ScoredBeatFingering` from a per-beat `PitchFingering` list, precomputing
64    /// the difficulty features used to score pathfinding transitions.
65    pub(crate) fn new(beat_fingering_candidate: BeatVec<PitchFingering>) -> Self {
66        let avg_non_zero_fret = calc_avg_non_zero_fret(&beat_fingering_candidate);
67        let non_zero_fret_span = calc_fret_span(&beat_fingering_candidate).unwrap_or(0);
68
69        ScoredBeatFingering {
70            beat_fingering: beat_fingering_candidate,
71            avg_non_zero_fret,
72            non_zero_fret_span,
73        }
74    }
75}
76#[cfg(test)]
77mod test_create_scored_beat_fingering {
78    use super::*;
79    use crate::string_number::StringNumber;
80
81    #[test]
82    fn simple() {
83        let pitch_fingering_1 = PitchFingering {
84            pitch: Pitch::A0,
85            string_number: StringNumber::new(1).unwrap(),
86            fret: 2,
87        };
88
89        let ScoredBeatFingering {
90            beat_fingering,
91            avg_non_zero_fret,
92            non_zero_fret_span,
93        } = ScoredBeatFingering::new(vec![pitch_fingering_1]);
94
95        assert_eq!(beat_fingering, vec![pitch_fingering_1]);
96        assert_eq!(avg_non_zero_fret, Some(OrderedFloat(2.0)));
97        assert_eq!(non_zero_fret_span, 0);
98    }
99    #[test]
100    fn complex() {
101        let pitch_fingering_1 = PitchFingering {
102            pitch: Pitch::A0,
103            string_number: StringNumber::new(1).unwrap(),
104            fret: 2,
105        };
106        let pitch_fingering_2 = PitchFingering {
107            pitch: Pitch::B1,
108            string_number: StringNumber::new(2).unwrap(),
109            fret: 5,
110        };
111        let pitch_fingering_3 = PitchFingering {
112            pitch: Pitch::C2,
113            string_number: StringNumber::new(3).unwrap(),
114            fret: 0,
115        };
116        let pitch_fingering_4 = PitchFingering {
117            pitch: Pitch::D3,
118            string_number: StringNumber::new(4).unwrap(),
119            fret: 1,
120        };
121
122        let ScoredBeatFingering {
123            beat_fingering,
124            avg_non_zero_fret,
125            non_zero_fret_span,
126        } = ScoredBeatFingering::new(vec![
127            pitch_fingering_1,
128            pitch_fingering_2,
129            pitch_fingering_3,
130            pitch_fingering_4,
131        ]);
132
133        assert_eq!(
134            beat_fingering,
135            vec![
136                pitch_fingering_1,
137                pitch_fingering_2,
138                pitch_fingering_3,
139                pitch_fingering_4
140            ]
141        );
142        assert_eq!(avg_non_zero_fret, Some(OrderedFloat(8.0 / 3.0)));
143        assert_eq!(non_zero_fret_span, 4);
144    }
145}
146
147fn calc_avg_non_zero_fret(
148    beat_fingering_candidate: &[PitchFingering],
149) -> Option<OrderedFloat<f64>> {
150    let non_zero_fingerings = beat_fingering_candidate
151        .iter()
152        .filter(|fingering| fingering.fret != 0)
153        .map(|fingering| fingering.fret as f64)
154        .collect::<Mean>();
155
156    if non_zero_fingerings.is_empty() {
157        None
158    } else {
159        Some(OrderedFloat(non_zero_fingerings.mean()))
160    }
161}
162#[cfg(test)]
163mod test_calc_avg_non_zero_fret {
164    use super::*;
165    use crate::string_number::StringNumber;
166
167    #[test]
168    fn single_non_zero_fret() {
169        let pitch_fingering_1 = PitchFingering {
170            pitch: Pitch::A0,
171            string_number: StringNumber::new(1).unwrap(),
172            fret: 2,
173        };
174
175        assert_eq!(
176            calc_avg_non_zero_fret(&[pitch_fingering_1]),
177            Some(OrderedFloat(2.0))
178        );
179    }
180    #[test]
181    fn single_zero_fret() {
182        let pitch_fingering_1 = PitchFingering {
183            pitch: Pitch::A0,
184            string_number: StringNumber::new(1).unwrap(),
185            fret: 0,
186        };
187
188        assert_eq!(calc_avg_non_zero_fret(&[pitch_fingering_1]), None);
189    }
190    #[test]
191    fn multiple_zero_frets() {
192        let pitch_fingering_1 = PitchFingering {
193            pitch: Pitch::A0,
194            string_number: StringNumber::new(1).unwrap(),
195            fret: 0,
196        };
197        let pitch_fingering_2 = PitchFingering {
198            pitch: Pitch::B2,
199            string_number: StringNumber::new(2).unwrap(),
200            fret: 0,
201        };
202
203        assert_eq!(
204            calc_avg_non_zero_fret(&[pitch_fingering_1, pitch_fingering_2]),
205            None
206        );
207    }
208    #[test]
209    fn multiple_mixed_frets() {
210        let pitch_fingering_1 = PitchFingering {
211            pitch: Pitch::A0,
212            string_number: StringNumber::new(1).unwrap(),
213            fret: 2,
214        };
215        let pitch_fingering_2 = PitchFingering {
216            pitch: Pitch::B1,
217            string_number: StringNumber::new(2).unwrap(),
218            fret: 5,
219        };
220        let pitch_fingering_3 = PitchFingering {
221            pitch: Pitch::C2,
222            string_number: StringNumber::new(3).unwrap(),
223            fret: 0,
224        };
225        let pitch_fingering_4 = PitchFingering {
226            pitch: Pitch::D3,
227            string_number: StringNumber::new(4).unwrap(),
228            fret: 1,
229        };
230
231        assert_eq!(
232            calc_avg_non_zero_fret(&[
233                pitch_fingering_1,
234                pitch_fingering_2,
235                pitch_fingering_3,
236                pitch_fingering_4,
237            ]),
238            Some(OrderedFloat(8.0 / 3.0))
239        );
240    }
241}
242
243/// A single ranked guitar arrangement: one fingering choice per beat, ordered by line.
244#[derive(Debug, Clone, Eq, PartialEq)]
245pub struct Arrangement {
246    pub(crate) lines: Vec<Line<BeatVec<PitchFingering>>>,
247    difficulty: i32,
248    max_fret_span: u8,
249}
250impl Arrangement {
251    /// Pass directly to [`crate::render_tab`].
252    #[must_use]
253    pub fn lines(&self) -> &[Line<BeatVec<PitchFingering>>] {
254        &self.lines
255    }
256
257    /// The maximum non-zero fret span reached on any beat in this arrangement.
258    ///
259    /// Useful as a coarse "playability" gauge: a smaller span means less hand stretch.
260    #[must_use]
261    pub fn max_fret_span(&self) -> u8 {
262        self.max_fret_span
263    }
264
265    /// The difficulty score of this arrangement. Lower is easier. Equal to the sum of
266    /// transition difficulties along the chosen path through the fingering graph.
267    #[must_use]
268    pub fn difficulty(&self) -> i32 {
269        self.difficulty
270    }
271}
272#[cfg(test)]
273mod test_max_fret_span {
274    use super::*;
275
276    #[test]
277    fn test_max_fret_span() {
278        let arrangement = Arrangement {
279            lines: vec![],
280            difficulty: 4,
281            max_fret_span: 5,
282        };
283        assert_eq!(arrangement.max_fret_span(), 5);
284    }
285}
286
287/// Computes the N best-scoring guitar arrangements for a parsed sequence of pitches,
288/// ranked by ascending difficulty.
289///
290/// # Errors
291///
292/// Returns an error if any input line cannot be fingered on the supplied `guitar`
293/// (out-of-range pitches), or [`crate::error::TabError::InputTooManyLines`] when
294/// `input_lines` exceeds `MAX_INPUT_LINES`. `num_arrangements` is range-checked by
295/// [`crate::NumArrangements::try_new`] at construction.
296///
297/// # Panics
298///
299/// Panics only if an internal invariant is violated (a BUG condition, not reachable
300/// under any valid input): a `MeasureBreak` line leaking past the pathfinding filter,
301/// or a `Node::Start` appearing as a future node during path traversal.
302#[memoize(Capacity: 10)]
303pub fn create_arrangements(
304    guitar: Guitar,
305    input_lines: Vec<Line<BeatVec<Pitch>>>,
306    num_arrangements: crate::NumArrangements,
307    max_fret_span_filter: Option<u8>,
308) -> Result<Vec<Arrangement>, TabError> {
309    // Reject input past the cap up front: each beat's line index is cast to `u16` below, so a
310    // longer sequence would silently wrap. `parse_lines` enforces the same bound, so this only
311    // fires for a direct caller that skips it.
312    if input_lines.len() > crate::parser::MAX_INPUT_LINES {
313        return Err(TabError::InputTooManyLines {
314            max: crate::parser::MAX_INPUT_LINES as u32,
315        });
316    }
317
318    let input_playable_lines = input_lines
319        .iter()
320        .filter(|line| matches!(line, Line::Playable(_)))
321        .collect_vec();
322    if input_playable_lines.is_empty() {
323        let empty_arrangements = vec![
324            Arrangement {
325                lines: vec![],
326                difficulty: 0,
327                max_fret_span: 0,
328            };
329            num_arrangements.get() as usize
330        ];
331        return Ok(empty_arrangements);
332    }
333
334    let first_playable_index = first_playable_index(&input_lines);
335
336    // Validate against the full input so `UnplayablePitch.line` carries the original 1-indexed
337    // input line, then drop the leading rests for pathfinding. Skipping before validation would
338    // report the line relative to the post-skip beat sequence (off by the leading-rest count).
339    let pitch_fingering_candidates: Vec<Line<BeatVec<PitchVec<PitchFingering>>>> =
340        validate_fingerings(&guitar, &input_lines)?
341            .into_iter()
342            .skip(first_playable_index)
343            .collect_vec();
344
345    let measure_break_indices: Vec<usize> = pitch_fingering_candidates
346        .iter()
347        .enumerate()
348        .filter(|(.., line_candidate)| matches!(line_candidate, MeasureBreak))
349        .map(|(line_index, ..)| line_index)
350        .collect_vec();
351
352    let path_node_groups: Vec<BeatVec<Node>> = pitch_fingering_candidates
353        .into_iter()
354        .filter(|line_candidate| !matches!(line_candidate, MeasureBreak))
355        .enumerate()
356        .map(|(line_index, line_candidate)| match line_candidate {
357            MeasureBreak => unreachable!("Measure breaks should have been filtered out."),
358            // `line_index as u16` cannot truncate: the guard above caps input at
359            // `MAX_INPUT_LINES` (`u16::MAX`), so the beat index always fits.
360            Rest => vec![Node::Rest {
361                line_index: line_index as u16,
362            }],
363            Playable(beat_fingerings_per_pitch) => {
364                generate_beat_fingerings(&beat_fingerings_per_pitch)
365                    .into_iter()
366                    .map(|pitch_fingering_group| Node::Playable {
367                        line_index: line_index as u16,
368                        scored_beat_fingering: Rc::new(ScoredBeatFingering::new(
369                            pitch_fingering_group,
370                        )),
371                    })
372                    .collect()
373            }
374        })
375        .collect::<Vec<_>>();
376
377    let num_path_node_groups = path_node_groups.len();
378
379    let path_nodes: Vec<Node> = path_node_groups.into_iter().flatten().collect_vec();
380
381    let path_results: Vec<(Vec<Node>, i32)> = yen(
382        &Node::Start,
383        |current_node| calc_next_nodes(current_node, &path_nodes),
384        |current_node| match current_node {
385            Node::Start => false,
386            Node::Rest { line_index } | Node::Playable { line_index, .. } => {
387                // Pathfinding goal is reached when the node is in the last node group
388                *line_index == (num_path_node_groups - 1) as u16
389            }
390        },
391        num_arrangements.get() as usize,
392    );
393    if path_results.is_empty() {
394        return Err(TabError::NoArrangementsFound);
395    }
396
397    let mut arrangements = path_results
398        .into_iter()
399        .map(|path_result| process_path(path_result.0, path_result.1, &measure_break_indices))
400        .collect_vec();
401
402    if let Some(max_span) = max_fret_span_filter {
403        arrangements.retain(|a| a.max_fret_span() <= max_span);
404    }
405
406    Ok(arrangements)
407}
408#[cfg(test)]
409mod test_create_arrangements {
410    use super::*;
411    use crate::NumArrangements;
412    use crate::parser::parse_lines;
413    use crate::string_number::StringNumber;
414
415    #[test]
416    fn unreachable_pitch_returns_unplayable_pitches_variant() {
417        let lines = parse_lines("A1".to_owned()).unwrap();
418        let n = NumArrangements::try_new(1).unwrap();
419        let err = create_arrangements(Guitar::default(), lines, n, None).unwrap_err();
420        match err {
421            TabError::UnplayablePitches { pitches } => {
422                assert_eq!(pitches.len(), 1);
423                assert_eq!(pitches[0].value, "A1");
424                assert_eq!(pitches[0].line, 1);
425            }
426            other => panic!("expected UnplayablePitches, got {other:?}"),
427        }
428    }
429
430    #[test]
431    fn empty_playable_beat_yields_no_arrangements() {
432        // A structurally valid but degenerate input: a beat with no pitches. This previously
433        // tripped an `assert!` in `generate_beat_fingerings` and panicked; it now reports
434        // `NoArrangementsFound`, like any other beat with zero candidate fingerings.
435        let input_pitches = vec![Line::Playable(vec![])];
436
437        let err = create_arrangements(
438            Guitar::default(),
439            input_pitches,
440            NumArrangements::try_new(1).unwrap(),
441            None,
442        )
443        .unwrap_err();
444
445        assert!(matches!(err, TabError::NoArrangementsFound), "got {err:?}");
446    }
447
448    #[test]
449    fn single_line_single_pitch() {
450        let input_pitches: Vec<Line<BeatVec<Pitch>>> = vec![Line::Playable(vec![Pitch::E4])];
451        let expected_arrangements: Vec<Arrangement> = vec![Arrangement {
452            lines: vec![Line::Playable(vec![PitchFingering {
453                pitch: Pitch::E4,
454                string_number: StringNumber::new(1).unwrap(),
455                fret: 0,
456            }])],
457            difficulty: 0,
458            max_fret_span: 0,
459        }];
460
461        let arrangements = create_arrangements(
462            Guitar::default(),
463            input_pitches,
464            NumArrangements::try_new(1).unwrap(),
465            None,
466        )
467        .unwrap();
468
469        assert_eq!(arrangements, expected_arrangements);
470    }
471    #[test]
472    fn single_line_single_pitch_multiple_arrangements() {
473        let input_pitches: Vec<Line<BeatVec<Pitch>>> = vec![Line::Playable(vec![Pitch::E4])];
474        let expected_arrangements: Vec<Arrangement> = vec![
475            Arrangement {
476                lines: vec![Line::Playable(vec![PitchFingering {
477                    pitch: Pitch::E4,
478                    string_number: StringNumber::new(1).unwrap(),
479                    fret: 0,
480                }])],
481                difficulty: 0,
482                max_fret_span: 0,
483            },
484            Arrangement {
485                lines: vec![Line::Playable(vec![PitchFingering {
486                    pitch: Pitch::E4,
487                    string_number: StringNumber::new(2).unwrap(),
488                    fret: 5,
489                }])],
490                difficulty: 5,
491                max_fret_span: 0,
492            },
493            Arrangement {
494                lines: vec![Line::Playable(vec![PitchFingering {
495                    pitch: Pitch::E4,
496                    string_number: StringNumber::new(3).unwrap(),
497                    fret: 9,
498                }])],
499                difficulty: 9,
500                max_fret_span: 0,
501            },
502            Arrangement {
503                lines: vec![Line::Playable(vec![PitchFingering {
504                    pitch: Pitch::E4,
505                    string_number: StringNumber::new(4).unwrap(),
506                    fret: 14,
507                }])],
508                difficulty: 14,
509                max_fret_span: 0,
510            },
511        ];
512
513        let arrangements = create_arrangements(
514            Guitar::default(),
515            input_pitches,
516            NumArrangements::try_new(10).unwrap(),
517            None,
518        )
519        .unwrap();
520
521        assert_eq!(arrangements, expected_arrangements);
522    }
523    #[test]
524    fn single_lines_all_variants() {
525        let input_pitches: Vec<Line<BeatVec<Pitch>>> = vec![
526            Line::Playable(vec![Pitch::E4]),
527            Line::Rest,
528            Line::MeasureBreak,
529        ];
530        let expected_arrangements: Vec<Arrangement> = vec![Arrangement {
531            lines: vec![
532                Line::Playable(vec![PitchFingering {
533                    pitch: Pitch::E4,
534                    string_number: StringNumber::new(1).unwrap(),
535                    fret: 0,
536                }]),
537                Line::Rest,
538                Line::MeasureBreak,
539            ],
540            difficulty: 0,
541            max_fret_span: 0,
542        }];
543
544        let arrangements = create_arrangements(
545            Guitar::default(),
546            input_pitches,
547            NumArrangements::try_new(1).unwrap(),
548            None,
549        )
550        .unwrap();
551
552        assert_eq!(arrangements, expected_arrangements);
553    }
554    #[test]
555    fn duplicate_pitches_in_beat_yield_no_arrangements() {
556        // Each E2 is individually playable, but the no-duplicate-strings constraint filters
557        // every fingering combination for the beat, so pathfinding finds no route and
558        // `create_arrangements` reports `NoArrangementsFound`.
559        let input_pitches = vec![Line::Playable(vec![Pitch::E2, Pitch::E2])];
560
561        let err = create_arrangements(
562            Guitar::default(),
563            input_pitches,
564            NumArrangements::try_new(1).unwrap(),
565            None,
566        )
567        .unwrap_err();
568
569        assert!(matches!(err, TabError::NoArrangementsFound), "got {err:?}");
570    }
571    #[test]
572    fn input_beyond_max_lines_returns_input_too_many_lines() {
573        // `create_arrangements` casts each beat's line index to `u16`, so it must reject input
574        // past the cap itself rather than trusting every caller to pre-cap. All-rest input
575        // exceeds the cap without driving pathfinding, so the guard is cheap to exercise.
576        let input_pitches = vec![Line::Rest; crate::parser::MAX_INPUT_LINES + 1];
577
578        let result = create_arrangements(
579            Guitar::default(),
580            input_pitches,
581            NumArrangements::try_new(1).unwrap(),
582            None,
583        );
584
585        match result {
586            Err(TabError::InputTooManyLines { max }) => {
587                assert_eq!(max, crate::parser::MAX_INPUT_LINES as u32);
588            }
589            other => panic!("expected Err(InputTooManyLines), got {other:?}"),
590        }
591    }
592    #[test]
593    fn empty_input() {
594        let input_pitches: Vec<Line<BeatVec<Pitch>>> = vec![];
595
596        let arrangements = create_arrangements(
597            Guitar::default(),
598            input_pitches,
599            NumArrangements::try_new(2).unwrap(),
600            None,
601        )
602        .unwrap();
603
604        let expected_arrangements: Vec<Arrangement> = vec![
605            Arrangement {
606                lines: vec![],
607                difficulty: 0,
608                max_fret_span: 0,
609            };
610            2
611        ];
612
613        assert_eq!(arrangements, expected_arrangements);
614    }
615    #[test]
616    fn empty_start_lines_input() {
617        let input_pitches: Vec<Line<BeatVec<Pitch>>> = vec![
618            Line::Rest,
619            Line::MeasureBreak,
620            Line::Rest,
621            Line::Playable(vec![Pitch::E4]),
622            Line::Rest,
623        ];
624
625        let arrangements = create_arrangements(
626            Guitar::default(),
627            input_pitches,
628            NumArrangements::try_new(1).unwrap(),
629            None,
630        )
631        .unwrap();
632
633        let expected_arrangements: Vec<Arrangement> = vec![Arrangement {
634            lines: vec![
635                Line::Playable(vec![PitchFingering {
636                    pitch: Pitch::E4,
637                    string_number: StringNumber::new(1).unwrap(),
638                    fret: 0,
639                }]),
640                Line::Rest,
641            ],
642            difficulty: 0,
643            max_fret_span: 0,
644        }];
645
646        assert_eq!(arrangements, expected_arrangements);
647    }
648    #[test]
649    fn max_fret_span_filter_drops_high_span_arrangements() {
650        let tuning =
651            crate::guitar::create_string_tuning(&crate::guitar::STD_6_STRING_TUNING_OPEN_PITCHES)
652                .unwrap();
653        let guitar = crate::guitar::Guitar::new(tuning, 20, 0).unwrap();
654        // G2B4 is a chord beat: G2 lands at fret 3 on string 6, B4 at fret 7 on string 1.
655        // Some arrangements will have both notes at non-zero frets, producing a span > 0.
656        let lines = crate::parser::parse_lines("G2B4".to_owned()).unwrap();
657
658        // Without a filter, at least one arrangement has a non-zero fret span.
659        let unfiltered = create_arrangements(
660            guitar.clone(),
661            lines.clone(),
662            NumArrangements::try_new(5).unwrap(),
663            None,
664        )
665        .unwrap();
666        assert!(unfiltered.iter().any(|a| a.max_fret_span() > 0));
667
668        // With filter = Some(0), only arrangements that never stretch survive.
669        let filtered = create_arrangements(
670            guitar.clone(),
671            lines,
672            NumArrangements::try_new(5).unwrap(),
673            Some(0),
674        )
675        .unwrap();
676        assert!(filtered.iter().all(|a| a.max_fret_span() == 0));
677        assert!(filtered.len() <= 5);
678    }
679    #[test]
680    fn max_fret_span_filter_can_produce_empty_set() {
681        let tuning =
682            crate::guitar::create_string_tuning(&crate::guitar::STD_6_STRING_TUNING_OPEN_PITCHES)
683                .unwrap();
684        let guitar = crate::guitar::Guitar::new(tuning, 20, 0).unwrap();
685        // C3E3 forces both notes onto fretted positions (neither is an open string in
686        // standard tuning), so every candidate arrangement has a non-zero fret span.
687        let lines = crate::parser::parse_lines("C3E3".to_owned()).unwrap();
688
689        let filtered =
690            create_arrangements(guitar, lines, NumArrangements::try_new(5).unwrap(), Some(0))
691                .expect("filter dropping every candidate is not an error");
692        assert!(
693            filtered.is_empty(),
694            "max_fret_span_filter=Some(0) on an all-fretted chord must drop every candidate",
695        );
696    }
697
698    #[test]
699    fn max_fret_span_filter_keeps_only_low_span_arrangements() {
700        let tuning =
701            crate::guitar::create_string_tuning(&crate::guitar::STD_6_STRING_TUNING_OPEN_PITCHES)
702                .unwrap();
703        let guitar = crate::guitar::Guitar::new(tuning, 20, 0).unwrap();
704        // C3G3: G3 is open on string 3, C3 must be fretted. Across the 5 best arrangements,
705        // some keep the fretted notes tight (span 0) and some stretch (span > 0), so a
706        // Some(0) filter drops a strict subset rather than all or none.
707        let lines = crate::parser::parse_lines("C3G3".to_owned()).unwrap();
708
709        let unfiltered = create_arrangements(
710            guitar.clone(),
711            lines.clone(),
712            NumArrangements::try_new(5).unwrap(),
713            None,
714        )
715        .unwrap();
716        assert_eq!(unfiltered.len(), 5);
717        assert!(
718            unfiltered.iter().any(|a| a.max_fret_span() == 0),
719            "expected at least one span-0 arrangement"
720        );
721        assert!(
722            unfiltered.iter().any(|a| a.max_fret_span() > 0),
723            "expected at least one span>0 arrangement so the filter does real work"
724        );
725
726        let filtered =
727            create_arrangements(guitar, lines, NumArrangements::try_new(5).unwrap(), Some(0))
728                .unwrap();
729        // Exactly the two span-0 arrangements survive: 0 < 2 < 5, exercising the
730        // "return what we have" fallback (fewer than num_arrangements, no error).
731        assert_eq!(filtered.len(), 2);
732        assert!(filtered.iter().all(|a| a.max_fret_span() == 0));
733    }
734}
735
736/// Generates the candidate `PitchFingering`s for every pitch in each beat.
737///
738/// Returns the per-beat fingerings on success, or [`TabError::UnplayablePitches`] listing
739/// every pitch that reached no string on the configured guitar (with its 1-indexed input
740/// line). All unplayable pitches are collected before returning, not just the first.
741///
742/// * `guitar`: the configured guitar, supplying per-string ranges.
743/// * `input_pitches`: the parsed beats to place.
744fn validate_fingerings(
745    guitar: &Guitar,
746    input_pitches: &[Line<BeatVec<Pitch>>],
747) -> Result<Vec<Line<BeatVec<PitchVec<PitchFingering>>>>, TabError> {
748    let mut unplayable_pitches: Vec<UnplayablePitch> = vec![];
749    let fingerings: Vec<Line<BeatVec<PitchVec<PitchFingering>>>> = input_pitches
750        .iter()
751        .enumerate()
752        .map(|(beat_index, beat_input)| match beat_input {
753            MeasureBreak => MeasureBreak,
754            Rest => Rest,
755            Playable(beat_pitches) => Playable(
756                beat_pitches
757                    .iter()
758                    .map(|beat_pitch| {
759                        let pitch_fingerings: PitchVec<PitchFingering> =
760                            generate_pitch_fingerings(&guitar.string_ranges, beat_pitch);
761                        if pitch_fingerings.is_empty() {
762                            unplayable_pitches.push(UnplayablePitch {
763                                value: beat_pitch.plain_text().to_owned(),
764                                line: (beat_index as u32) + 1,
765                            })
766                        }
767                        pitch_fingerings
768                    })
769                    .collect(),
770            ),
771        })
772        .collect();
773
774    if !unplayable_pitches.is_empty() {
775        return Err(TabError::UnplayablePitches {
776            pitches: unplayable_pitches,
777        });
778    }
779
780    Ok(fingerings)
781}
782#[cfg(test)]
783mod test_validate_fingerings {
784    use super::*;
785
786    #[test]
787    fn valid_simple() {
788        let guitar = Guitar::default();
789        let input_pitches = vec![Playable(vec![Pitch::G3])];
790        let expected_fingerings = vec![Playable(vec![generate_pitch_fingerings(
791            &guitar.string_ranges,
792            &Pitch::G3,
793        )])];
794
795        assert_eq!(
796            validate_fingerings(&guitar, &input_pitches).unwrap(),
797            expected_fingerings
798        );
799    }
800    #[test]
801    fn valid_complex() {
802        let guitar = Guitar::default();
803        let input_pitches = vec![
804            Playable(vec![Pitch::G3]),
805            MeasureBreak,
806            Playable(vec![Pitch::B3]),
807            Rest,
808            Playable(vec![Pitch::D4, Pitch::G4]),
809        ];
810        let expected_fingerings = vec![
811            Playable(vec![generate_pitch_fingerings(
812                &guitar.string_ranges,
813                &Pitch::G3,
814            )]),
815            MeasureBreak,
816            Playable(vec![generate_pitch_fingerings(
817                &guitar.string_ranges,
818                &Pitch::B3,
819            )]),
820            Rest,
821            Playable(vec![
822                generate_pitch_fingerings(&guitar.string_ranges, &Pitch::D4),
823                generate_pitch_fingerings(&guitar.string_ranges, &Pitch::G4),
824            ]),
825        ];
826
827        assert_eq!(
828            validate_fingerings(&guitar, &input_pitches).unwrap(),
829            expected_fingerings
830        );
831    }
832    #[test]
833    fn invalid_simple() {
834        let guitar = Guitar::default();
835        let input_pitches = vec![Playable(vec![Pitch::B9])];
836
837        let err = validate_fingerings(&guitar, &input_pitches).unwrap_err();
838        match err {
839            TabError::UnplayablePitches { pitches } => {
840                assert_eq!(pitches.len(), 1);
841                assert_eq!(pitches[0].value, "B9");
842                assert_eq!(pitches[0].line, 1);
843            }
844            other => panic!("expected UnplayablePitches, got {other:?}"),
845        }
846    }
847    #[test]
848    fn invalid_accidental_reports_plain_text_spelling() {
849        // An unplayable accidental reports its plain-text spelling ("Db9"), matching the
850        // normalized-input pitch strings, rather than the internal enum name ("CSharpDFlat9").
851        let guitar = Guitar::default();
852        let input_pitches = vec![Playable(vec![Pitch::CSharpDFlat9])];
853
854        let err = validate_fingerings(&guitar, &input_pitches).unwrap_err();
855        match err {
856            TabError::UnplayablePitches { pitches } => {
857                assert_eq!(pitches.len(), 1);
858                assert_eq!(pitches[0].value, "Db9");
859                assert_eq!(pitches[0].line, 1);
860            }
861            other => panic!("expected UnplayablePitches, got {other:?}"),
862        }
863    }
864    #[test]
865    fn invalid_complex() {
866        let guitar = Guitar::default();
867        let input_pitches = vec![
868            Playable(vec![Pitch::A1]),
869            Playable(vec![Pitch::G3]),
870            Playable(vec![Pitch::B3]),
871            Playable(vec![Pitch::A1, Pitch::B1]),
872            Playable(vec![Pitch::G3, Pitch::D2]),
873            Playable(vec![Pitch::D4, Pitch::G4]),
874        ];
875
876        let err = validate_fingerings(&guitar, &input_pitches).unwrap_err();
877        match err {
878            TabError::UnplayablePitches { pitches } => {
879                assert_eq!(pitches.len(), 4);
880                assert_eq!(pitches[0].value, "A1");
881                assert_eq!(pitches[0].line, 1);
882                assert_eq!(pitches[1].value, "A1");
883                assert_eq!(pitches[1].line, 4);
884                assert_eq!(pitches[2].value, "B1");
885                assert_eq!(pitches[2].line, 4);
886                assert_eq!(pitches[3].value, "D2");
887                assert_eq!(pitches[3].line, 5);
888            }
889            other => panic!("expected UnplayablePitches, got {other:?}"),
890        }
891    }
892}
893
894/// Generates all playable combinations of fingerings for all the pitches in a beat.
895/// An empty beat yields no combinations.
896fn generate_beat_fingerings(
897    beat_fingerings_per_pitch: &[Vec<PitchFingering>],
898) -> Vec<BeatVec<PitchFingering>> {
899    // No pitches means no combinations. Returning early avoids relying on
900    // `multi_cartesian_product`'s ambiguous empty-input result; the empty candidate set
901    // then flows to `NoArrangementsFound` through pathfinding, like a beat whose fingerings
902    // are all dropped by `no_duplicate_strings`.
903    if beat_fingerings_per_pitch.is_empty() {
904        return Vec::new();
905    }
906
907    beat_fingerings_per_pitch
908        .iter()
909        .multi_cartesian_product()
910        .map(|combo| combo.into_iter().copied().collect::<Vec<PitchFingering>>())
911        .filter(|x| no_duplicate_strings(x))
912        .collect()
913}
914#[cfg(test)]
915mod test_generate_beat_fingerings {
916    use super::*;
917    use crate::string_number::StringNumber;
918
919    #[test]
920    fn simple() {
921        let pitch_fingering = PitchFingering {
922            pitch: Pitch::B6,
923            string_number: StringNumber::new(2).unwrap(),
924            fret: 3,
925        };
926
927        let beat_fingerings_per_pitch = &[vec![pitch_fingering]];
928
929        assert_eq!(
930            generate_beat_fingerings(beat_fingerings_per_pitch),
931            beat_fingerings_per_pitch
932        );
933    }
934    #[test]
935    fn complex() {
936        let pitch_fingering_a_string_2 = PitchFingering {
937            pitch: Pitch::B6,
938            string_number: StringNumber::new(2).unwrap(),
939            fret: 3,
940        };
941        let pitch_fingering_a_string_3 = PitchFingering {
942            pitch: Pitch::B6,
943            string_number: StringNumber::new(3).unwrap(),
944            fret: 8,
945        };
946        let pitch_fingering_b_string_2 = PitchFingering {
947            pitch: Pitch::C7,
948            string_number: StringNumber::new(2).unwrap(),
949            fret: 4,
950        };
951        let pitch_fingering_b_string_3 = PitchFingering {
952            pitch: Pitch::C7,
953            string_number: StringNumber::new(3).unwrap(),
954            fret: 9,
955        };
956        let pitch_fingering_b_string_4 = PitchFingering {
957            pitch: Pitch::C7,
958            string_number: StringNumber::new(4).unwrap(),
959            fret: 14,
960        };
961
962        let beat_fingerings_per_pitch = vec![
963            vec![pitch_fingering_a_string_2, pitch_fingering_a_string_3],
964            vec![
965                pitch_fingering_b_string_2,
966                pitch_fingering_b_string_3,
967                pitch_fingering_b_string_4,
968            ],
969        ];
970        let expected_beat_fingerings = vec![
971            vec![pitch_fingering_a_string_2, pitch_fingering_b_string_3],
972            vec![pitch_fingering_a_string_2, pitch_fingering_b_string_4],
973            vec![pitch_fingering_a_string_3, pitch_fingering_b_string_2],
974            vec![pitch_fingering_a_string_3, pitch_fingering_b_string_4],
975        ];
976
977        assert_eq!(
978            generate_beat_fingerings(&beat_fingerings_per_pitch),
979            expected_beat_fingerings
980        );
981    }
982
983    #[test]
984    fn empty_input_returns_no_combinations() {
985        assert_eq!(
986            generate_beat_fingerings(&[]),
987            Vec::<BeatVec<PitchFingering>>::new()
988        );
989    }
990}
991
992/// Checks if there are any duplicate strings in a vector of `Fingering`
993/// objects to ensure that all pitches can be played.
994fn no_duplicate_strings(beat_fingering_option: &[PitchFingering]) -> bool {
995    let mut seen_strings = HashSet::with_capacity(beat_fingering_option.len());
996    beat_fingering_option
997        .iter()
998        .all(|fingering| seen_strings.insert(fingering.string_number))
999}
1000#[cfg(test)]
1001mod test_no_duplicate_strings {
1002    use super::*;
1003    use crate::string_number::StringNumber;
1004
1005    #[test]
1006    fn valid_simple() {
1007        let fingering_1 = PitchFingering {
1008            pitch: Pitch::B6,
1009            string_number: StringNumber::new(2).unwrap(),
1010            fret: 3,
1011        };
1012
1013        assert!(no_duplicate_strings(&[fingering_1]));
1014    }
1015    #[test]
1016    fn valid_complex() {
1017        let fingering_1 = PitchFingering {
1018            pitch: Pitch::CSharpDFlat2,
1019            string_number: StringNumber::new(1).unwrap(),
1020            fret: 1,
1021        };
1022        let fingering_2 = PitchFingering {
1023            pitch: Pitch::F4,
1024            string_number: StringNumber::new(2).unwrap(),
1025            fret: 3,
1026        };
1027        let fingering_3 = PitchFingering {
1028            pitch: Pitch::A5,
1029            string_number: StringNumber::new(4).unwrap(),
1030            fret: 4,
1031        };
1032        let fingering_4 = PitchFingering {
1033            pitch: Pitch::DSharpEFlat6,
1034            string_number: StringNumber::new(11).unwrap(),
1035            fret: 0,
1036        };
1037
1038        assert!(no_duplicate_strings(&[
1039            fingering_1,
1040            fingering_2,
1041            fingering_3,
1042            fingering_4
1043        ]));
1044    }
1045    #[test]
1046    fn invalid_simple() {
1047        let fingering_1 = PitchFingering {
1048            pitch: Pitch::CSharpDFlat2,
1049            string_number: StringNumber::new(4).unwrap(),
1050            fret: 1,
1051        };
1052        let fingering_2 = PitchFingering {
1053            pitch: Pitch::F4,
1054            string_number: StringNumber::new(4).unwrap(),
1055            fret: 3,
1056        };
1057
1058        assert!(!no_duplicate_strings(&[fingering_1, fingering_2]));
1059    }
1060    #[test]
1061    fn invalid_complex() {
1062        let fingering_1 = PitchFingering {
1063            pitch: Pitch::CSharpDFlat2,
1064            string_number: StringNumber::new(1).unwrap(),
1065            fret: 1,
1066        };
1067        let fingering_2 = PitchFingering {
1068            pitch: Pitch::F4,
1069            string_number: StringNumber::new(3).unwrap(),
1070            fret: 3,
1071        };
1072        let fingering_3 = PitchFingering {
1073            pitch: Pitch::A5,
1074            string_number: StringNumber::new(6).unwrap(),
1075            fret: 4,
1076        };
1077        let fingering_4 = PitchFingering {
1078            pitch: Pitch::DSharpEFlat6,
1079            string_number: StringNumber::new(3).unwrap(),
1080            fret: 0,
1081        };
1082
1083        assert!(!no_duplicate_strings(&[
1084            fingering_1,
1085            fingering_2,
1086            fingering_3,
1087            fingering_4,
1088        ]));
1089    }
1090    #[test]
1091    fn empty_input() {
1092        assert!(no_duplicate_strings(&[]));
1093    }
1094}
1095
1096/// Calculates the difference between the maximum and minimum non-zero
1097/// fret numbers in a given vector of fingerings.
1098fn calc_fret_span(beat_fingering_candidate: &[PitchFingering]) -> Option<u8> {
1099    use itertools::MinMaxResult;
1100
1101    let non_zero_frets = beat_fingering_candidate
1102        .iter()
1103        .filter(|fingering| fingering.fret != 0)
1104        .map(|fingering| fingering.fret);
1105
1106    match non_zero_frets.minmax() {
1107        MinMaxResult::NoElements => None,
1108        MinMaxResult::OneElement(_) => Some(0),
1109        // `minmax()` guarantees `max >= min`, so this `u8` subtraction cannot underflow.
1110        MinMaxResult::MinMax(min, max) => Some(max - min),
1111    }
1112}
1113#[cfg(test)]
1114mod test_calc_fret_span {
1115    use super::*;
1116    use crate::string_number::StringNumber;
1117
1118    #[test]
1119    fn simple() {
1120        let fingering_1 = PitchFingering {
1121            pitch: Pitch::B6,
1122            string_number: StringNumber::new(2).unwrap(),
1123            fret: 3,
1124        };
1125
1126        assert_eq!(calc_fret_span(&[fingering_1]).unwrap(), 0);
1127    }
1128    #[test]
1129    fn complex() {
1130        let fingering_1 = PitchFingering {
1131            pitch: Pitch::CSharpDFlat2,
1132            string_number: StringNumber::new(1).unwrap(),
1133            fret: 1,
1134        };
1135        let fingering_2 = PitchFingering {
1136            pitch: Pitch::F4,
1137            string_number: StringNumber::new(2).unwrap(),
1138            fret: 3,
1139        };
1140        let fingering_3 = PitchFingering {
1141            pitch: Pitch::A5,
1142            string_number: StringNumber::new(4).unwrap(),
1143            fret: 4,
1144        };
1145        let fingering_4 = PitchFingering {
1146            pitch: Pitch::DSharpEFlat6,
1147            string_number: StringNumber::new(11).unwrap(),
1148            fret: 0,
1149        };
1150        let beat_fingering_option = &[fingering_1, fingering_2, fingering_3, fingering_4];
1151
1152        assert_eq!(calc_fret_span(beat_fingering_option).unwrap(), 3);
1153    }
1154    #[test]
1155    fn empty_input() {
1156        assert!(calc_fret_span(&[]).is_none());
1157    }
1158}
1159
1160type NodeDifficulty = i32;
1161
1162/// Calculates the next nodes and their transition difficulties based on the current node
1163/// and a list of all path nodes.
1164///
1165/// Returns a vector of tuples, where each tuple contains a `Node` and the `NodeDifficulty`
1166/// of moving to that node.
1167fn calc_next_nodes(current_node: &Node, path_nodes: &[Node]) -> Vec<(Node, NodeDifficulty)> {
1168    let next_node_index = match current_node {
1169        Node::Start => 0,
1170        // `create_arrangements` caps accepted input at `MAX_INPUT_LINES` (`u16::MAX`), so the
1171        // largest `line_index` is `u16::MAX - 1` and this `+ 1` reaches at most `u16::MAX`: no
1172        // overflow.
1173        Node::Rest { line_index } | Node::Playable { line_index, .. } => line_index + 1,
1174    };
1175
1176    let next_nodes: Vec<(Node, NodeDifficulty)> = path_nodes
1177        .iter()
1178        .filter(|&node| {
1179            next_node_index
1180                == match node {
1181                    Node::Start => unreachable!("Start should never be a future node."),
1182                    Node::Rest { line_index } | Node::Playable { line_index, .. } => *line_index,
1183                }
1184        })
1185        .map(|next_node| {
1186            (
1187                next_node.clone(),
1188                calculate_node_difficulty(current_node, next_node),
1189            )
1190        })
1191        .collect_vec();
1192
1193    next_nodes
1194}
1195#[cfg(test)]
1196mod test_calc_next_nodes {
1197    use super::*;
1198
1199    fn create_test_path_nodes() -> [Node; 7] {
1200        [
1201            Node::Playable {
1202                line_index: 0,
1203                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1204                    beat_fingering: vec![],
1205                    avg_non_zero_fret: Some(OrderedFloat(0.1)),
1206                    non_zero_fret_span: 0,
1207                }),
1208            },
1209            Node::Playable {
1210                line_index: 0,
1211                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1212                    beat_fingering: vec![],
1213                    avg_non_zero_fret: Some(OrderedFloat(0.2)),
1214                    non_zero_fret_span: 0,
1215                }),
1216            },
1217            Node::Playable {
1218                line_index: 1,
1219                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1220                    beat_fingering: vec![],
1221                    avg_non_zero_fret: Some(OrderedFloat(1.1)),
1222                    non_zero_fret_span: 1,
1223                }),
1224            },
1225            Node::Rest { line_index: 2 },
1226            Node::Rest { line_index: 3 },
1227            Node::Playable {
1228                line_index: 4,
1229                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1230                    beat_fingering: vec![],
1231                    avg_non_zero_fret: Some(OrderedFloat(4.1)),
1232                    non_zero_fret_span: 4,
1233                }),
1234            },
1235            Node::Playable {
1236                line_index: 4,
1237                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1238                    beat_fingering: vec![],
1239                    avg_non_zero_fret: Some(OrderedFloat(4.1)),
1240                    non_zero_fret_span: 4,
1241                }),
1242            },
1243        ]
1244    }
1245
1246    #[test]
1247    fn from_start_to_note() {
1248        let current_node = Node::Start;
1249
1250        let expected_nodes_and_costs = [
1251            Node::Playable {
1252                line_index: 0,
1253                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1254                    beat_fingering: vec![],
1255                    avg_non_zero_fret: Some(OrderedFloat(0.1)),
1256                    non_zero_fret_span: 0,
1257                }),
1258            },
1259            Node::Playable {
1260                line_index: 0,
1261                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1262                    beat_fingering: vec![],
1263                    avg_non_zero_fret: Some(OrderedFloat(0.2)),
1264                    non_zero_fret_span: 0,
1265                }),
1266            },
1267        ]
1268        .iter()
1269        .map(|node| (node.clone(), calculate_node_difficulty(&current_node, node)))
1270        .collect_vec();
1271
1272        assert_eq!(
1273            calc_next_nodes(&current_node, &create_test_path_nodes()),
1274            expected_nodes_and_costs
1275        );
1276    }
1277    #[test]
1278    fn from_note_to_note() {
1279        let current_node = Node::Playable {
1280            line_index: 0,
1281            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1282                beat_fingering: vec![],
1283                avg_non_zero_fret: Some(OrderedFloat(0.1)),
1284                non_zero_fret_span: 0,
1285            }),
1286        };
1287
1288        let expected_nodes_and_costs = [Node::Playable {
1289            line_index: 1,
1290            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1291                beat_fingering: vec![],
1292                avg_non_zero_fret: Some(OrderedFloat(1.1)),
1293                non_zero_fret_span: 1,
1294            }),
1295        }]
1296        .iter()
1297        .map(|node| (node.clone(), calculate_node_difficulty(&current_node, node)))
1298        .collect_vec();
1299
1300        assert_eq!(
1301            calc_next_nodes(&current_node, &create_test_path_nodes()),
1302            expected_nodes_and_costs
1303        );
1304    }
1305    #[test]
1306    fn from_note_to_rest() {
1307        let current_node = Node::Playable {
1308            line_index: 1,
1309            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1310                beat_fingering: vec![],
1311                avg_non_zero_fret: Some(OrderedFloat(1.1)),
1312                non_zero_fret_span: 1,
1313            }),
1314        };
1315
1316        let expected_nodes_and_costs = [Node::Rest { line_index: 2 }]
1317            .iter()
1318            .map(|node| (node.clone(), calculate_node_difficulty(&current_node, node)))
1319            .collect_vec();
1320
1321        assert_eq!(
1322            calc_next_nodes(&current_node, &create_test_path_nodes()),
1323            expected_nodes_and_costs
1324        );
1325    }
1326    #[test]
1327    fn from_rest_to_rest() {
1328        let current_node = Node::Rest { line_index: 2 };
1329
1330        let expected_nodes_and_costs = [Node::Rest { line_index: 3 }]
1331            .iter()
1332            .map(|node| (node.clone(), calculate_node_difficulty(&current_node, node)))
1333            .collect_vec();
1334
1335        assert_eq!(
1336            calc_next_nodes(&current_node, &create_test_path_nodes()),
1337            expected_nodes_and_costs
1338        );
1339    }
1340    #[test]
1341    fn from_rest_to_note() {
1342        let current_node = Node::Rest { line_index: 3 };
1343
1344        let expected_nodes_and_costs = [
1345            Node::Playable {
1346                line_index: 4,
1347                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1348                    beat_fingering: vec![],
1349                    avg_non_zero_fret: Some(OrderedFloat(4.1)),
1350                    non_zero_fret_span: 4,
1351                }),
1352            },
1353            Node::Playable {
1354                line_index: 4,
1355                scored_beat_fingering: Rc::new(ScoredBeatFingering {
1356                    beat_fingering: vec![],
1357                    avg_non_zero_fret: Some(OrderedFloat(4.1)),
1358                    non_zero_fret_span: 4,
1359                }),
1360            },
1361        ]
1362        .iter()
1363        .map(|node| (node.clone(), calculate_node_difficulty(&current_node, node)))
1364        .collect_vec();
1365
1366        assert_eq!(
1367            calc_next_nodes(&current_node, &create_test_path_nodes()),
1368            expected_nodes_and_costs
1369        );
1370    }
1371
1372    #[test]
1373    #[should_panic]
1374    fn to_start() {
1375        calc_next_nodes(
1376            &Node::Rest { line_index: 3 },
1377            &[Node::Rest { line_index: 4 }, Node::Start],
1378        );
1379    }
1380}
1381
1382/// Calculates the transition difficulty from one node to another based on the
1383/// average fret difference and fret span.
1384fn calculate_node_difficulty(current_node: &Node, next_node: &Node) -> NodeDifficulty {
1385    let current_avg_fret = match current_node {
1386        Node::Playable {
1387            scored_beat_fingering,
1388            ..
1389        } => scored_beat_fingering.avg_non_zero_fret,
1390        _ => None,
1391    };
1392
1393    let (next_avg_fret, next_fret_span) = match next_node {
1394        Node::Start => unreachable!("Start should never be a future node."),
1395        Node::Rest { .. } => (None, 0.0),
1396        Node::Playable {
1397            scored_beat_fingering,
1398            ..
1399        } => (
1400            scored_beat_fingering.avg_non_zero_fret,
1401            scored_beat_fingering.non_zero_fret_span as f64,
1402        ),
1403    };
1404
1405    let avg_fret_difference = match (current_avg_fret, next_avg_fret) {
1406        (Some(current_avg_fret_num), Some(next_avg_fret_num)) => {
1407            (next_avg_fret_num - current_avg_fret_num).abs()
1408        }
1409        _ => 0.0,
1410    };
1411
1412    // The cast to i32 (NodeDifficulty) cannot overflow: every fret term is bounded by
1413    // Guitar::MAX_NUM_FRETS (30), so the weighted sum stays far inside i32, and the inputs are
1414    // finite because calc_avg_non_zero_fret yields None (scored as 0.0) for an all-open beat.
1415    ((avg_fret_difference * 100.0)
1416        + (next_fret_span * 10.0)
1417        + (next_avg_fret.unwrap_or(OrderedFloat(0.0))).into_inner()) as NodeDifficulty
1418}
1419#[cfg(test)]
1420mod test_calculate_node_difficulty {
1421    use super::*;
1422
1423    #[test]
1424    fn simple_no_diff() {
1425        let current_node = Node::Playable {
1426            line_index: 0,
1427            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1428                beat_fingering: vec![],
1429                avg_non_zero_fret: Some(OrderedFloat(3.5)),
1430                non_zero_fret_span: 0,
1431            }),
1432        };
1433        let next_node = Node::Playable {
1434            line_index: 1,
1435            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1436                beat_fingering: vec![],
1437                avg_non_zero_fret: Some(OrderedFloat(3.5)),
1438                non_zero_fret_span: 0,
1439            }),
1440        };
1441
1442        assert_eq!(calculate_node_difficulty(&current_node, &next_node), 3);
1443    }
1444    #[test]
1445    fn simple_from_start() {
1446        let next_node = Node::Playable {
1447            line_index: 1,
1448            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1449                beat_fingering: vec![],
1450                avg_non_zero_fret: Some(OrderedFloat(3.5)),
1451                non_zero_fret_span: 0,
1452            }),
1453        };
1454
1455        assert_eq!(calculate_node_difficulty(&Node::Start, &next_node), 3);
1456    }
1457    #[test]
1458    fn simple_from_rest() {
1459        let next_node = Node::Playable {
1460            line_index: 1,
1461            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1462                beat_fingering: vec![],
1463                avg_non_zero_fret: Some(OrderedFloat(3.5)),
1464                non_zero_fret_span: 0,
1465            }),
1466        };
1467
1468        assert_eq!(
1469            calculate_node_difficulty(&Node::Rest { line_index: 0 }, &next_node),
1470            3
1471        );
1472    }
1473    #[test]
1474    fn simple_to_rest() {
1475        let current_node = Node::Playable {
1476            line_index: 0,
1477            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1478                beat_fingering: vec![],
1479                avg_non_zero_fret: Some(OrderedFloat(3.5)),
1480                non_zero_fret_span: 0,
1481            }),
1482        };
1483
1484        assert_eq!(
1485            calculate_node_difficulty(&current_node, &Node::Rest { line_index: 1 }),
1486            0
1487        );
1488    }
1489    #[test]
1490    fn simple_avg_fret_diff() {
1491        let current_node = Node::Playable {
1492            line_index: 0,
1493            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1494                beat_fingering: vec![],
1495                avg_non_zero_fret: Some(OrderedFloat(3.0)),
1496                non_zero_fret_span: 0,
1497            }),
1498        };
1499        let next_node = Node::Playable {
1500            line_index: 1,
1501            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1502                beat_fingering: vec![],
1503                avg_non_zero_fret: Some(OrderedFloat(1.6)),
1504                non_zero_fret_span: 0,
1505            }),
1506        };
1507
1508        assert_eq!(calculate_node_difficulty(&current_node, &next_node), 141);
1509    }
1510    #[test]
1511    fn simple_fret_span() {
1512        let current_node = Node::Playable {
1513            line_index: 0,
1514            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1515                beat_fingering: vec![],
1516                avg_non_zero_fret: Some(OrderedFloat(4.133333)),
1517                non_zero_fret_span: 0,
1518            }),
1519        };
1520        let next_node = Node::Playable {
1521            line_index: 1,
1522            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1523                beat_fingering: vec![],
1524                avg_non_zero_fret: Some(OrderedFloat(4.133333)),
1525                non_zero_fret_span: 3,
1526            }),
1527        };
1528
1529        assert_eq!(calculate_node_difficulty(&current_node, &next_node), 34);
1530    }
1531    #[test]
1532    fn compound() {
1533        let current_node = Node::Playable {
1534            line_index: 0,
1535            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1536                beat_fingering: vec![],
1537                avg_non_zero_fret: Some(OrderedFloat(5.0)),
1538                non_zero_fret_span: 0,
1539            }),
1540        };
1541        let next_node = Node::Playable {
1542            line_index: 1,
1543            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1544                beat_fingering: vec![],
1545                avg_non_zero_fret: Some(OrderedFloat(2.0)),
1546                non_zero_fret_span: 5,
1547            }),
1548        };
1549
1550        assert_eq!(calculate_node_difficulty(&current_node, &next_node), 352);
1551    }
1552    #[test]
1553    fn complex() {
1554        let current_node = Node::Playable {
1555            line_index: 0,
1556            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1557                beat_fingering: vec![],
1558                avg_non_zero_fret: Some(OrderedFloat(7.3333333)),
1559                non_zero_fret_span: 0,
1560            }),
1561        };
1562        let next_node = Node::Playable {
1563            line_index: 1,
1564            scored_beat_fingering: Rc::new(ScoredBeatFingering {
1565                beat_fingering: vec![],
1566                avg_non_zero_fret: Some(OrderedFloat(3.6666666)),
1567                non_zero_fret_span: 4,
1568            }),
1569        };
1570
1571        assert_eq!(calculate_node_difficulty(&current_node, &next_node), 410);
1572    }
1573}
1574
1575fn process_path(
1576    path_nodes: Vec<Node>,
1577    path_difficulty: i32,
1578    measure_break_indices: &[usize],
1579) -> Arrangement {
1580    let mut lines: Vec<Line<BeatVec<PitchFingering>>> = path_nodes
1581        .iter()
1582        .filter(|node| node != &&Node::Start)
1583        .map(|node| match node {
1584            Node::Start => unreachable!("Start node should have been filtered out."),
1585            Node::Rest { .. } => Line::Rest,
1586            Node::Playable {
1587                scored_beat_fingering,
1588                ..
1589            } => Line::Playable(scored_beat_fingering.beat_fingering.clone()),
1590        })
1591        .collect_vec();
1592    // Re-inject measure breaks. `measure_break_indices` is built by `enumerate().filter()`
1593    // upstream, so it is already ascending; inserting low to high lands each break at its
1594    // original post-skip slot without shifting an earlier one.
1595    for &measure_break_index in measure_break_indices {
1596        lines.insert(measure_break_index, Line::MeasureBreak);
1597    }
1598
1599    let max_fret_span: u8 = path_nodes
1600        .iter()
1601        .filter(|node| node != &&Node::Start)
1602        .filter_map(|node| match node {
1603            Node::Start => unreachable!("Start node should have been filtered out."),
1604            Node::Rest { .. } => None,
1605            Node::Playable {
1606                scored_beat_fingering,
1607                ..
1608            } => Some(scored_beat_fingering.non_zero_fret_span),
1609        })
1610        .max()
1611        .unwrap_or(0);
1612
1613    Arrangement {
1614        lines,
1615        difficulty: path_difficulty,
1616        max_fret_span,
1617    }
1618}
1619#[cfg(test)]
1620mod test_process_path {
1621    use super::*;
1622    use crate::string_number::StringNumber;
1623
1624    #[test]
1625    fn simple() {
1626        let placeholder_scored_beat_fingering = ScoredBeatFingering {
1627            beat_fingering: vec![PitchFingering {
1628                pitch: Pitch::C4,
1629                string_number: StringNumber::new(1).unwrap(),
1630                fret: 3,
1631            }],
1632            avg_non_zero_fret: Some(OrderedFloat(3.0)),
1633            non_zero_fret_span: 0,
1634        };
1635
1636        let path_nodes = vec![
1637            Node::Start,
1638            Node::Playable {
1639                line_index: 0,
1640                scored_beat_fingering: Rc::new(placeholder_scored_beat_fingering.clone()),
1641            },
1642        ];
1643
1644        let arrangement = process_path(path_nodes, 123, &[]);
1645
1646        let expected_arrangement = Arrangement {
1647            lines: vec![Playable(placeholder_scored_beat_fingering.beat_fingering)],
1648            difficulty: 123,
1649            max_fret_span: 0,
1650        };
1651
1652        assert_eq!(arrangement, expected_arrangement);
1653    }
1654    #[test]
1655    fn complex() {
1656        let placeholder_scored_beat_fingering = ScoredBeatFingering {
1657            beat_fingering: vec![PitchFingering {
1658                pitch: Pitch::C4,
1659                string_number: StringNumber::new(1).unwrap(),
1660                fret: 3,
1661            }],
1662            avg_non_zero_fret: Some(OrderedFloat(3.0)),
1663            non_zero_fret_span: 4,
1664        };
1665
1666        let path_nodes = vec![
1667            Node::Start,
1668            Node::Playable {
1669                line_index: 0,
1670                scored_beat_fingering: Rc::new(placeholder_scored_beat_fingering.clone()),
1671            },
1672            Node::Playable {
1673                line_index: 1,
1674                scored_beat_fingering: Rc::new(placeholder_scored_beat_fingering.clone()),
1675            },
1676            Node::Rest { line_index: 2 },
1677            Node::Playable {
1678                line_index: 3,
1679                scored_beat_fingering: Rc::new(placeholder_scored_beat_fingering.clone()),
1680            },
1681            Node::Playable {
1682                line_index: 4,
1683                scored_beat_fingering: Rc::new(placeholder_scored_beat_fingering.clone()),
1684            },
1685        ];
1686
1687        let arrangement = process_path(path_nodes, 321, &[0, 2, 5, 7]);
1688
1689        let expected_arrangement = Arrangement {
1690            lines: vec![
1691                MeasureBreak,
1692                Playable(placeholder_scored_beat_fingering.clone().beat_fingering),
1693                MeasureBreak,
1694                Playable(placeholder_scored_beat_fingering.clone().beat_fingering),
1695                Rest,
1696                MeasureBreak,
1697                Playable(placeholder_scored_beat_fingering.clone().beat_fingering),
1698                MeasureBreak,
1699                Playable(placeholder_scored_beat_fingering.beat_fingering),
1700            ],
1701            difficulty: 321,
1702            max_fret_span: 4,
1703        };
1704
1705        assert_eq!(arrangement, expected_arrangement);
1706    }
1707}
1708
1709// `proptest` is a non-wasm dev-dependency (it does not compile for `wasm32`), so this module
1710// is gated off the wasm test build alongside it.
1711#[cfg(all(test, not(target_arch = "wasm32")))]
1712mod proptest_invariants {
1713    use super::*;
1714    use crate::NumArrangements;
1715    use crate::guitar::{STD_6_STRING_TUNING_OPEN_PITCHES, create_string_tuning};
1716    use proptest::prelude::*;
1717    use std::collections::HashSet;
1718
1719    fn any_pitch() -> impl Strategy<Value = Pitch> {
1720        // E2 (index 28) through C6 (index 72): a comfortable range for a std-tuned
1721        // 6-string guitar and ensures generate_pitch_fingerings returns >=1 candidate.
1722        (28usize..=72usize).prop_map(|idx| Pitch::from_repr(idx).expect("BUG: index in range"))
1723    }
1724
1725    #[derive(Debug, Clone)]
1726    #[allow(dead_code)] // fields are surfaced via Debug when proptest shrinks a failing case
1727    struct ArrangementCase {
1728        input_lines: Vec<Line<BeatVec<Pitch>>>,
1729        num_arrangements: NumArrangements,
1730        measure_break_positions: Vec<usize>,
1731        rest_positions: Vec<usize>,
1732        playable_pitches_per_line: Vec<Vec<Pitch>>,
1733    }
1734
1735    fn arb_case() -> impl Strategy<Value = ArrangementCase> {
1736        (
1737            prop::collection::vec(
1738                (
1739                    prop::collection::vec(any_pitch(), 1..=3),
1740                    any::<u8>(), // kind selector
1741                ),
1742                1..=6,
1743            ),
1744            1u8..=5u8,
1745        )
1746            .prop_map(|(line_specs, num_arrangements)| {
1747                let mut input_lines: Vec<Line<BeatVec<Pitch>>> =
1748                    Vec::with_capacity(line_specs.len());
1749                let mut measure_break_positions = Vec::new();
1750                let mut rest_positions = Vec::new();
1751                let mut playable_pitches_per_line = Vec::new();
1752
1753                for (idx, (pitches, kind_byte)) in line_specs.into_iter().enumerate() {
1754                    match kind_byte % 8 {
1755                        0 => {
1756                            input_lines.push(Line::Rest);
1757                            rest_positions.push(idx);
1758                            playable_pitches_per_line.push(Vec::new());
1759                        }
1760                        1 => {
1761                            input_lines.push(Line::MeasureBreak);
1762                            measure_break_positions.push(idx);
1763                            playable_pitches_per_line.push(Vec::new());
1764                        }
1765                        _ => {
1766                            playable_pitches_per_line.push(pitches.clone());
1767                            input_lines.push(Line::Playable(pitches));
1768                        }
1769                    }
1770                }
1771
1772                // Ensure at least one playable line so create_arrangements doesn't short-circuit.
1773                if !input_lines.iter().any(|l| matches!(l, Line::Playable(_))) {
1774                    input_lines[0] = Line::Playable(vec![Pitch::E4]);
1775                    if let Some(pos) = rest_positions.iter().position(|&p| p == 0) {
1776                        rest_positions.remove(pos);
1777                    }
1778                    if let Some(pos) = measure_break_positions.iter().position(|&p| p == 0) {
1779                        measure_break_positions.remove(pos);
1780                    }
1781                    playable_pitches_per_line[0] = vec![Pitch::E4];
1782                }
1783
1784                ArrangementCase {
1785                    input_lines,
1786                    num_arrangements: NumArrangements::try_new(num_arrangements)
1787                        .expect("BUG: strategy generates 1..=5"),
1788                    measure_break_positions,
1789                    rest_positions,
1790                    playable_pitches_per_line,
1791                }
1792            })
1793    }
1794
1795    fn std_guitar() -> Guitar {
1796        let tuning = create_string_tuning(&STD_6_STRING_TUNING_OPEN_PITCHES)
1797            .expect("BUG: standard tuning is always valid");
1798        Guitar::new(tuning, 18, 0).expect("BUG: std guitar is always valid")
1799    }
1800
1801    proptest! {
1802        #![proptest_config(ProptestConfig::with_cases(32))]
1803
1804        // Invariant 1: every input pitch is represented in each arrangement at the matching
1805        // line_index via one of its candidate fingerings.
1806        #[test]
1807        fn invariant_input_pitches_represented(case in arb_case()) {
1808            let guitar = std_guitar();
1809            let arrangements = create_arrangements(
1810                guitar.clone(), case.input_lines.clone(), case.num_arrangements, None,
1811            ).map_err(|e| TestCaseError::reject(format!("create_arrangements rejected input: {e}")))?;
1812
1813            // Map input line_index (skipping leading non-playable lines) to expected pitches.
1814            let first_playable = case.input_lines
1815                .iter()
1816                .position(|l| matches!(l, Line::Playable(_)))
1817                .unwrap_or(0);
1818            let effective_lines: Vec<&Line<BeatVec<Pitch>>> = case.input_lines
1819                .iter()
1820                .skip(first_playable)
1821                .collect();
1822
1823            for arrangement in &arrangements {
1824                prop_assert_eq!(arrangement.lines.len(), effective_lines.len());
1825                for (idx, (input_line, output_line)) in
1826                    effective_lines.iter().zip(arrangement.lines.iter()).enumerate()
1827                {
1828                    match (input_line, output_line) {
1829                        (Line::Playable(input_pitches), Line::Playable(fingerings)) => {
1830                            prop_assert_eq!(
1831                                fingerings.len(), input_pitches.len(),
1832                                "line {} fingering count mismatch", idx
1833                            );
1834                            let output_pitches: HashSet<Pitch> =
1835                                fingerings.iter().map(|f| f.pitch).collect();
1836                            let expected_pitches: HashSet<Pitch> =
1837                                input_pitches.iter().copied().collect();
1838                            prop_assert_eq!(
1839                                output_pitches, expected_pitches,
1840                                "line {} pitch mismatch", idx
1841                            );
1842                        }
1843                        (Line::Rest, Line::Rest) | (Line::MeasureBreak, Line::MeasureBreak) => {}
1844                        _ => prop_assert!(false, "line {} variant mismatch", idx),
1845                    }
1846                }
1847            }
1848        }
1849
1850        // Invariant 2: no two fingerings in the same beat share a string_number.
1851        #[test]
1852        fn invariant_no_duplicate_strings(case in arb_case()) {
1853            let guitar = std_guitar();
1854            let arrangements = create_arrangements(
1855                guitar, case.input_lines, case.num_arrangements, None,
1856            ).map_err(|e| TestCaseError::reject(format!("create_arrangements rejected input: {e}")))?;
1857
1858            for arrangement in &arrangements {
1859                for line in &arrangement.lines {
1860                    if let Line::Playable(fingerings) = line {
1861                        let mut seen = HashSet::new();
1862                        for f in fingerings {
1863                            prop_assert!(
1864                                seen.insert(f.string_number),
1865                                "duplicate string_number in beat"
1866                            );
1867                        }
1868                    }
1869                }
1870            }
1871        }
1872
1873        // Invariant 3: every fret is in [0, num_frets].
1874        #[test]
1875        fn invariant_fret_bounds(case in arb_case()) {
1876            let guitar = std_guitar();
1877            let playable_frets = guitar.playable_frets;
1878            let arrangements = create_arrangements(
1879                guitar, case.input_lines, case.num_arrangements, None,
1880            ).map_err(|e| TestCaseError::reject(format!("create_arrangements rejected input: {e}")))?;
1881
1882            for arrangement in &arrangements {
1883                for line in &arrangement.lines {
1884                    if let Line::Playable(fingerings) = line {
1885                        for f in fingerings {
1886                            prop_assert!(f.fret <= playable_frets);
1887                        }
1888                    }
1889                }
1890            }
1891        }
1892
1893        // Invariant 4: arrangements are sorted by ascending difficulty.
1894        #[test]
1895        fn invariant_sorted_by_difficulty(case in arb_case()) {
1896            let guitar = std_guitar();
1897            let arrangements = create_arrangements(
1898                guitar, case.input_lines, case.num_arrangements, None,
1899            ).map_err(|e| TestCaseError::reject(format!("create_arrangements rejected input: {e}")))?;
1900
1901            for pair in arrangements.windows(2) {
1902                prop_assert!(pair[0].difficulty <= pair[1].difficulty);
1903            }
1904        }
1905
1906        // Invariant 5: the number of arrangements returned is at most the requested max.
1907        #[test]
1908        fn invariant_count_bounded(case in arb_case()) {
1909            let guitar = std_guitar();
1910            let arrangements = create_arrangements(
1911                guitar, case.input_lines, case.num_arrangements, None,
1912            ).map_err(|e| TestCaseError::reject(format!("create_arrangements rejected input: {e}")))?;
1913
1914            prop_assert!(arrangements.len() <= case.num_arrangements.get() as usize);
1915        }
1916
1917        // Invariant 6: deterministic. Same input produces the same output twice.
1918        // Both calls go through the uncached `memoized_original_create_arrangements`. The
1919        // memoized `create_arrangements` would serve the second call from its cache and never
1920        // re-run the pathfinder, which would make the comparison trivially true.
1921        #[test]
1922        fn invariant_deterministic(case in arb_case()) {
1923            let guitar1 = std_guitar();
1924            let guitar2 = std_guitar();
1925            let first = memoized_original_create_arrangements(
1926                guitar1, case.input_lines.clone(), case.num_arrangements, None,
1927            );
1928            let second = memoized_original_create_arrangements(
1929                guitar2, case.input_lines, case.num_arrangements, None,
1930            );
1931            match (first, second) {
1932                (Ok(a), Ok(b)) => prop_assert_eq!(a, b),
1933                (Err(a), Err(b)) => prop_assert_eq!(a, b),
1934                _ => prop_assert!(false, "determinism violated: outcomes differ"),
1935            }
1936        }
1937    }
1938}