Skip to main content

sound_to_patch/music/
check.rs

1//! The rules every entry of the music dictionary meets, the coverage of the
2//! tiers and the recomputed shares. [`dictionary`](super::dictionary)
3//! refuses data that breaks one.
4
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6
7use serde::Deserialize;
8
9use crate::cast;
10use crate::music::Dictionary;
11use crate::music::chord::ALTERED_DOMINANT;
12use crate::music::entry::{Entry, Fragment, Group, Rule, Tier};
13use crate::notes::{HIGHEST_MODEL_PITCH, LOWEST_MODEL_PITCH};
14use crate::preset::registry::registry_for;
15
16/// The roles an entry may name. `drum` and `fx` have no range row.
17pub const ROLES: [&str; 13] = [
18    "sub-bass", "808", "bass", "lead", "pad", "keys", "pluck", "arp", "vocal", "chords", "stab",
19    "drum", "fx",
20];
21
22/// The parts an onset rule or a fragment note may name.
23pub const PARTS: [&str; 11] = [
24    "any", "kick", "snare", "clap", "rim", "hat", "open-hat", "ride", "bass", "chord", "lead",
25];
26
27/// The largest gap between a recomputed share and the stated one, in
28/// units of the last stated decimal.
29const ROUNDING: f64 = 0.5;
30
31/// The largest sum of the shares of one exclusive set, in percent.
32const SET_SUM: f64 = 100.5;
33
34const SOURCES: &str = include_str!("../../dictionary/sources.json");
35
36#[derive(Deserialize)]
37struct Source {
38    id: String,
39}
40
41/// Every broken rule, as text that names the entry. Empty when the data is
42/// sound.
43#[must_use]
44pub fn problems(dictionary: &Dictionary) -> Vec<String> {
45    let mut found = Vec::new();
46    let sources: BTreeSet<String> = match serde_json::from_str::<Vec<Source>>(SOURCES) {
47        Ok(list) => list.into_iter().map(|source| source.id).collect(),
48        Err(error) => {
49            found.push(format!("dictionary/sources.json: {error}"));
50            BTreeSet::new()
51        }
52    };
53    let mut ids = BTreeSet::new();
54    for entry in &dictionary.entries {
55        if !ids.insert(entry.id.as_str()) {
56            found.push(format!("{}: the id appears twice", entry.id));
57        }
58    }
59    for topic in super::TOPICS {
60        for entry in dictionary.topic(topic.name) {
61            if !topic.groups.contains(&entry.group) {
62                found.push(format!(
63                    "{}: group {} does not belong in {}.json",
64                    entry.id,
65                    entry.group.as_str(),
66                    topic.name
67                ));
68            }
69        }
70    }
71    for entry in &dictionary.entries {
72        for problem in entry_problems(entry, dictionary, &sources) {
73            found.push(format!("{}: {problem}", entry.id));
74        }
75    }
76    let mut symbols: HashMap<&str, &str> = HashMap::new();
77    for entry in dictionary
78        .entries
79        .iter()
80        .filter(|entry| entry.group == Group::Chord)
81    {
82        for symbol in &entry.symbols {
83            if let Some(other) = symbols.insert(symbol, &entry.id) {
84                found.push(format!(
85                    "{}: symbol `{symbol}` is also in {other}",
86                    entry.id
87                ));
88            }
89        }
90    }
91    found.extend(sense_problems(dictionary));
92    found.extend(stat_problems(dictionary, &sources));
93    found
94}
95
96fn entry_problems(
97    entry: &Entry,
98    dictionary: &Dictionary,
99    sources: &BTreeSet<String>,
100) -> Vec<String> {
101    let mut found = Vec::new();
102    let prefix = format!("{}.", entry.group.as_str());
103    if !entry.id.starts_with(&prefix) {
104        found.push(format!("the id must start with `{prefix}`"));
105    }
106    match (&entry.sense, entry.id.rsplit('.').next()) {
107        (Some(sense), Some(last)) if sense != last => {
108            found.push(format!("the id must end with the sense `{sense}`"));
109        }
110        _ => {}
111    }
112    for role in &entry.roles {
113        if !ROLES.contains(&role.as_str()) {
114            found.push(format!("unknown role `{role}`"));
115        }
116    }
117    for id in &entry.conflicts {
118        if dictionary.entry(id).is_none() {
119            found.push(format!("conflict `{id}` is no entry"));
120        }
121    }
122    for id in &entry.genres {
123        if dictionary
124            .entry(id)
125            .is_none_or(|genre| genre.group != Group::Genre)
126        {
127            found.push(format!("genre `{id}` is no genre entry"));
128        }
129    }
130    for source in &entry.source {
131        if !sources.contains(source) {
132            found.push(format!(
133                "source `{source}` is not in dictionary/sources.json"
134            ));
135        }
136    }
137    if !is_date(&entry.verified) {
138        found.push(format!(
139            "verified `{}` is not a YYYY-MM-DD date",
140            entry.verified
141        ));
142    }
143    let names_tones = matches!(entry.rule, Rule::ChordTones { .. });
144    if entry.group == Group::Chord && names_tones && entry.symbols.is_empty() {
145        found.push("a chord entry with chord_tones lists its symbols".into());
146    }
147    if entry.group != Group::Chord && !entry.symbols.is_empty() {
148        found.push("only chord entries list symbols".into());
149    }
150    if let Some(scale) = &entry.scale {
151        if entry.group != Group::Progression {
152            found.push("only progression entries name a scale".into());
153        }
154        if !is_scale(dictionary, scale) {
155            found.push(format!("scale `{scale}` is no scale entry"));
156        } else if let (Some(Rule::PitchSet { offsets, .. }), Rule::Cycle { roots, .. }) = (
157            dictionary.entry(scale).map(|picked| &picked.rule),
158            &entry.rule,
159        ) && roots.iter().any(|root| !offsets.contains(root))
160        {
161            found.push(format!("a cycle root lies outside its scale `{scale}`"));
162        }
163    }
164    if let Some(pick) = &entry.default_pick {
165        for id in pick.scale.iter().chain(&pick.alternatives) {
166            if !is_scale(dictionary, id) {
167                found.push(format!("default pick `{id}` is no scale entry"));
168            }
169        }
170        let picked = pick.scale.as_deref().and_then(|id| dictionary.entry(id));
171        if let (Group::Scale, Some(picked)) = (entry.group, picked)
172            && offsets(&entry.rule) != offsets(&picked.rule)
173        {
174            found.push(format!(
175                "a scale-name sense needs the offsets of its pick `{}`",
176                picked.id
177            ));
178        }
179    }
180    if let Rule::ChordTones {
181        choose: Some(_), ..
182    } = &entry.rule
183        && entry.id != ALTERED_DOMINANT
184    {
185        found.push(format!("only `{ALTERED_DOMINANT}` may choose its tones"));
186    }
187    found.extend(control_problems(entry, sources));
188    found.extend(rule_problems(&entry.rule, dictionary));
189    found.extend(example_problems(entry));
190    found
191}
192
193fn example_problems(entry: &Entry) -> Vec<String> {
194    let mut found = Vec::new();
195    let examples = [&entry.example_pass, &entry.example_fail];
196    if matches!(entry.rule, Rule::None {}) {
197        if examples.iter().any(|example| example.is_some()) {
198            found.push("an entry with rule `none` has no examples".into());
199        }
200    } else {
201        for (name, example) in ["example_pass", "example_fail"].iter().zip(examples) {
202            match example {
203                None => found.push(format!("{name} is missing")),
204                Some(fragment) => {
205                    for problem in fragment_problems(fragment) {
206                        found.push(format!("{name}: {problem}"));
207                    }
208                }
209            }
210        }
211        if entry.example_pass.is_some() && entry.example_pass == entry.example_fail {
212            found.push("example_pass and example_fail are the same".into());
213        }
214    }
215    found
216}
217
218fn offsets(rule: &Rule) -> Option<&[u8]> {
219    match rule {
220        Rule::PitchSet { offsets, .. } => Some(offsets),
221        _ => None,
222    }
223}
224
225fn is_scale(dictionary: &Dictionary, id: &str) -> bool {
226    dictionary
227        .entry(id)
228        .is_some_and(|entry| entry.group == Group::Scale)
229}
230
231fn is_date(text: &str) -> bool {
232    let bytes = text.as_bytes();
233    bytes.len() == 10
234        && bytes.iter().enumerate().all(|(index, byte)| {
235            if index == 4 || index == 7 {
236                *byte == b'-'
237            } else {
238                byte.is_ascii_digit()
239            }
240        })
241}
242
243fn control_problems(entry: &Entry, sources: &BTreeSet<String>) -> Vec<String> {
244    let mut found = Vec::new();
245    for control in &entry.controls {
246        match registry_for(control.synth) {
247            Ok(registry) => {
248                if !registry
249                    .entries()
250                    .iter()
251                    .any(|spec| spec.label == control.label)
252                {
253                    found.push(format!(
254                        "control `{}` is no label of the {} registry",
255                        control.label, control.synth
256                    ));
257                }
258            }
259            Err(_) => found.push(format!(
260                "control `{}` names {}, which has no registry yet",
261                control.label, control.synth
262            )),
263        }
264        if !["up", "down", "on", "off", "set"].contains(&control.direction.as_str()) {
265            found.push(format!("control direction `{}`", control.direction));
266        }
267        if !["from facts", "measured", "uncalibrated"].contains(&control.tag.as_str()) {
268            found.push(format!("control tag `{}`", control.tag));
269        }
270        for source in &control.source {
271            if !sources.contains(source) {
272                found.push(format!(
273                    "control source `{source}` is not in dictionary/sources.json"
274                ));
275            }
276        }
277    }
278    found
279}
280
281fn rising(offsets: &[u8]) -> bool {
282    offsets.first() == Some(&0) && offsets.windows(2).all(|pair| pair[0] < pair[1])
283}
284
285fn rule_problems(rule: &Rule, dictionary: &Dictionary) -> Vec<String> {
286    let mut found = Vec::new();
287    match rule {
288        Rule::PitchSet {
289            offsets,
290            colour,
291            giveaway,
292        } => {
293            if !rising(offsets) || offsets.iter().any(|&offset| offset > 11) {
294                found
295                    .push("pitch_set offsets must start at 0, rise and stay within 0 to 11".into());
296            }
297            if colour.iter().any(|offset| !offsets.contains(offset)) {
298                found.push("a colour offset lies outside the scale".into());
299            }
300            if giveaway.iter().any(|offset| offsets.contains(offset)) {
301                found.push("a giveaway offset lies inside the scale".into());
302            }
303        }
304        Rule::ChordTones { offsets, choose } => {
305            if !rising(offsets) {
306                found.push("chord_tones offsets must start at 0 and rise".into());
307            }
308            if let Some(choose) = choose
309                && usize::from(choose.at_least) > choose.from.len()
310            {
311                found.push("choose asks for more tones than it offers".into());
312            }
313        }
314        Rule::Cycle { roots, chords, .. } => {
315            if roots.len() != chords.len() {
316                found.push("a cycle needs one chord per root".into());
317            }
318            for chord in chords {
319                if dictionary
320                    .entry(chord)
321                    .is_none_or(|entry| entry.group != Group::Chord)
322                {
323                    found.push(format!("cycle chord `{chord}` is no chord entry"));
324                }
325            }
326        }
327        Rule::OnsetSet { parts } => {
328            for part in parts {
329                if !PARTS.contains(&part.part.as_str()) {
330                    found.push(format!("unknown part `{}`", part.part));
331                }
332                if part.slots.iter().any(|&slot| slot > 15) {
333                    found.push("onset slots run from 0 to 15".into());
334                }
335            }
336        }
337        Rule::CountPerBar { min, max } | Rule::Velocity { min, max } if min > max => {
338            found.push("min exceeds max".into());
339        }
340        Rule::Length {
341            min: Some(min),
342            max: Some(max),
343            ..
344        } if min > max => found.push("min exceeds max".into()),
345        Rule::Swing {
346            min_pct, max_pct, ..
347        } if min_pct > max_pct => found.push("min_pct exceeds max_pct".into()),
348        Rule::Shift { min_ms, max_ms } if min_ms > max_ms => {
349            found.push("min_ms exceeds max_ms".into());
350        }
351        Rule::Register { core, hard, .. } => {
352            let inside = |range: [u8; 2]| {
353                range[0] <= range[1]
354                    && range[0] >= LOWEST_MODEL_PITCH
355                    && range[1] <= HIGHEST_MODEL_PITCH
356            };
357            if !inside(*core) || !inside(*hard) || core[0] < hard[0] || core[1] > hard[1] {
358                found.push(
359                    "the core range must lie inside the hard limits, and both inside 21 to 108"
360                        .into(),
361                );
362            }
363        }
364        Rule::Pattern { k, n, rotation } if k > n || rotation >= n => {
365            found.push("a pattern needs k <= n and rotation < n".into());
366        }
367        _ => {}
368    }
369    found
370}
371
372fn fragment_problems(fragment: &Fragment) -> Vec<String> {
373    let mut found = Vec::new();
374    if ![12, 16, 24].contains(&fragment.grid) {
375        found.push(format!("grid {} is not 12, 16 or 24", fragment.grid));
376    }
377    if fragment.root.is_some_and(|root| root > 11) {
378        found.push("root is a pitch class, 0 to 11".into());
379    }
380    if fragment.notes.is_empty() {
381        found.push("a fragment holds at least one note".into());
382    }
383    for note in &fragment.notes {
384        if !(LOWEST_MODEL_PITCH..=HIGHEST_MODEL_PITCH).contains(&note.pitch) {
385            found.push(format!("pitch {} lies outside 21 to 108", note.pitch));
386        }
387        if !(1..=127).contains(&note.vel) {
388            found.push(format!("velocity {} lies outside 1 to 127", note.vel));
389        }
390        if !(1..=100).contains(&note.gate) {
391            found.push(format!("gate {} lies outside 1 to 100", note.gate));
392        }
393        if !(note.len > 0.0 && note.len <= 64.0) {
394            found.push(format!("length {} beats lies outside 0 to 64", note.len));
395        }
396        if note
397            .part
398            .as_ref()
399            .is_some_and(|part| !PARTS.contains(&part.as_str()))
400        {
401            found.push("unknown part".into());
402        }
403    }
404    if fragment
405        .notes
406        .windows(2)
407        .any(|pair| pair[0].start > pair[1].start)
408    {
409        found.push("notes must be sorted by start".into());
410    }
411    found
412}
413
414/// The senses of each word: one entry per sense, distinct sense ids and
415/// exactly one default; an ask question offers senses of its own word.
416fn sense_problems(dictionary: &Dictionary) -> Vec<String> {
417    let mut found = Vec::new();
418    let mut words: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new();
419    for entry in &dictionary.entries {
420        words.entry(entry.word.as_str()).or_default().push(entry);
421    }
422    for (word, senses) in &words {
423        let defaults = senses.iter().filter(|entry| entry.default).count();
424        if defaults != 1 {
425            found.push(format!(
426                "word `{word}` has {defaults} default senses, not 1"
427            ));
428        }
429        if senses.len() > 1 {
430            let mut seen = BTreeSet::new();
431            for entry in senses {
432                match &entry.sense {
433                    None => found.push(format!(
434                        "{}: word `{word}` has several senses, so each needs a sense id",
435                        entry.id
436                    )),
437                    Some(sense) if !seen.insert(sense.as_str()) => {
438                        found.push(format!("word `{word}` has sense `{sense}` twice"));
439                    }
440                    Some(_) => {}
441                }
442            }
443        }
444    }
445    for entry in &dictionary.entries {
446        let Some(ask) = &entry.ask else { continue };
447        if !ask.options.contains(&ask.default) {
448            found.push(format!("{}: the ask default is not an option", entry.id));
449        }
450        for option in &ask.options {
451            if dictionary
452                .entry(option)
453                .is_none_or(|sense| sense.word != entry.word)
454            {
455                found.push(format!(
456                    "{}: ask option `{option}` is no sense of `{}`",
457                    entry.id, entry.word
458                ));
459            }
460        }
461    }
462    found
463}
464
465fn stat_problems(dictionary: &Dictionary, sources: &BTreeSet<String>) -> Vec<String> {
466    let mut found = Vec::new();
467    let mut ids = BTreeSet::new();
468    let mut sets: BTreeMap<&str, f64> = BTreeMap::new();
469    for stat in &dictionary.stats {
470        if !ids.insert(stat.id.as_str()) {
471            found.push(format!("stat {}: the id appears twice", stat.id));
472        }
473        if !(0.0..=100.0).contains(&stat.share) {
474            found.push(format!(
475                "stat {}: share {} lies outside 0 to 100",
476                stat.id, stat.share
477            ));
478        }
479        match (stat.count, stat.total) {
480            (Some(count), Some(total)) => {
481                if let Some(problem) = recompute(stat.share, count, total) {
482                    found.push(format!("stat {}: {problem}", stat.id));
483                }
484            }
485            (None, None) => {}
486            _ => found.push(format!(
487                "stat {}: give both count and total, or neither",
488                stat.id
489            )),
490        }
491        if let Some(set) = &stat.set {
492            *sets.entry(set.as_str()).or_default() += stat.share;
493        }
494        for source in &stat.source {
495            if !sources.contains(source) {
496                found.push(format!(
497                    "stat {}: source `{source}` is not in dictionary/sources.json",
498                    stat.id
499                ));
500            }
501        }
502    }
503    for (set, sum) in sets {
504        if sum > SET_SUM {
505            found.push(format!(
506                "stat set {set}: the shares sum to {sum:.1} %, over {SET_SUM} %"
507            ));
508        }
509    }
510    found
511}
512
513/// Recomputes a share from its counts; `None` when it matches the stated
514/// share at the stated decimals.
515#[must_use]
516pub fn recompute(share: f64, count: u64, total: u64) -> Option<String> {
517    if count > total {
518        return Some(format!("count {count} exceeds total {total}"));
519    }
520    let computed = cast::u64_to_f64(count) / cast::u64_to_f64(total) * 100.0;
521    let step = decimal_step(share);
522    if (computed - share).abs() <= step * ROUNDING + f64::EPSILON * 100.0 {
523        None
524    } else {
525        Some(format!(
526            "{count} of {total} is {computed:.3} %, not the stated {share} %"
527        ))
528    }
529}
530
531/// The value of one unit of the last decimal of `share`, as written in the
532/// shortest form that round-trips, and at least one decimal: JSON keeps
533/// no trailing zero, so `97.0` reads as `97`.
534fn decimal_step(share: f64) -> f64 {
535    let text = share.to_string();
536    let decimals = text
537        .split_once('.')
538        .map_or(1, |(_, fraction)| fraction.len().max(1));
539    let decimals = i32::try_from(decimals).unwrap_or(i32::MAX);
540    10f64.powi(-decimals)
541}
542
543/// One row of the coverage report.
544#[derive(Clone, Copy, Debug, PartialEq, Eq)]
545pub struct CoverageRow {
546    /// The group.
547    pub group: Group,
548    /// The tier.
549    pub tier: Tier,
550    /// The planned entries.
551    pub planned: u16,
552    /// The entries that landed, counting one per word.
553    pub landed: u16,
554}
555
556impl CoverageRow {
557    /// Whether the landed share reaches the floor of the tier.
558    #[must_use]
559    pub fn meets_floor(self) -> bool {
560        u32::from(self.landed) * 100 >= u32::from(self.planned) * u32::from(self.tier.floor())
561    }
562}
563
564/// The coverage of every planned group and tier. An entry counts once per
565/// word, through its default sense.
566#[must_use]
567pub fn coverage(dictionary: &Dictionary) -> Vec<CoverageRow> {
568    let mut landed: HashMap<(Group, Tier), u16> = HashMap::new();
569    for entry in dictionary.entries.iter().filter(|entry| entry.default) {
570        *landed.entry((entry.group, entry.tier)).or_default() += 1;
571    }
572    let mut rows = Vec::new();
573    for (group, tiers) in &dictionary.plan {
574        for (tier, planned) in tiers {
575            rows.push(CoverageRow {
576                group: *group,
577                tier: *tier,
578                planned: *planned,
579                landed: landed.get(&(*group, *tier)).copied().unwrap_or(0),
580            });
581        }
582    }
583    rows
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn recomputes_shares_at_the_stated_decimals() {
592        assert_eq!(recompute(34.2, 57_543, 168_242), None);
593        assert_eq!(recompute(53.1, 402, 757), None);
594        assert_eq!(recompute(96.7, 29, 30), None);
595        assert!(recompute(34.3, 57_543, 168_242).is_some());
596        assert!(recompute(97.0, 29, 30).is_some());
597        assert!(recompute(50.0, 3, 2).is_some());
598    }
599
600    #[test]
601    fn only_the_altered_dominant_chooses_its_tones() {
602        let dictionary = crate::music::dictionary().unwrap();
603        let Some(Rule::ChordTones { choose, .. }) =
604            dictionary.entry(ALTERED_DOMINANT).map(|entry| &entry.rule)
605        else {
606            panic!("{ALTERED_DOMINANT} has no chord tones");
607        };
608        let mut entry = dictionary.entry("chord.major-triad").unwrap().clone();
609        if let Rule::ChordTones { choose: other, .. } = &mut entry.rule {
610            other.clone_from(choose);
611        }
612        let problems = entry_problems(&entry, dictionary, &BTreeSet::new());
613        assert!(
614            problems
615                .iter()
616                .any(|problem| problem.contains("may choose its tones")),
617            "{problems:?}"
618        );
619    }
620
621    #[test]
622    fn a_progression_scale_must_be_a_scale_entry() {
623        let dictionary = crate::music::dictionary().unwrap();
624        let mut entry = dictionary
625            .entry("progression.mixolydian-loop")
626            .unwrap()
627            .clone();
628        entry.scale = Some("key.techno-key".into());
629        let problems = entry_problems(&entry, dictionary, &BTreeSet::new());
630        assert!(
631            problems
632                .iter()
633                .any(|problem| problem.contains("`key.techno-key` is no scale entry")),
634            "{problems:?}"
635        );
636    }
637
638    #[test]
639    fn a_tier_floor_counts_whole_percent() {
640        let row = |tier, planned, landed| CoverageRow {
641            group: Group::Scale,
642            tier,
643            planned,
644            landed,
645        };
646        assert!(row(Tier::M1, 8, 8).meets_floor());
647        assert!(!row(Tier::M1, 8, 7).meets_floor());
648        assert!(row(Tier::M2, 22, 11).meets_floor());
649        assert!(!row(Tier::M2, 22, 10).meets_floor());
650        assert!(row(Tier::M3, 27, 7).meets_floor());
651        assert!(!row(Tier::M3, 27, 6).meets_floor());
652    }
653}