1use 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
16pub const ROLES: [&str; 13] = [
18 "sub-bass", "808", "bass", "lead", "pad", "keys", "pluck", "arp", "vocal", "chords", "stab",
19 "drum", "fx",
20];
21
22pub const PARTS: [&str; 11] = [
24 "any", "kick", "snare", "clap", "rim", "hat", "open-hat", "ride", "bass", "chord", "lead",
25];
26
27const ROUNDING: f64 = 0.5;
30
31const 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#[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(¬e.pitch) {
385 found.push(format!("pitch {} lies outside 21 to 108", note.pitch));
386 }
387 if !(1..=127).contains(¬e.vel) {
388 found.push(format!("velocity {} lies outside 1 to 127", note.vel));
389 }
390 if !(1..=100).contains(¬e.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
414fn 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#[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
531fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
545pub struct CoverageRow {
546 pub group: Group,
548 pub tier: Tier,
550 pub planned: u16,
552 pub landed: u16,
554}
555
556impl CoverageRow {
557 #[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#[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}