Skip to main content

sound_to_patch/words/
check.rs

1//! The rules every word entry, listener number and list row must meet.
2//!
3//! [`problems`] recomputes each agreement class and few-sample flag from
4//! the stored numbers, compares every entry with the listener numbers of
5//! its word, and checks that each number names sources whose count its tag
6//! states.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::Deserialize;
11
12use crate::words::{Axis, Class, Entry, KNOWN_FROM, Tag, Value, Words};
13
14const SOURCES: &str = include_str!("../../dictionary/sources.json");
15
16/// Listener numbers below this count would come close to one row, so the
17/// script writes none.
18pub const AGGREGATE_FROM: u32 = 5;
19
20/// The neutral control groups a technique word may name.
21pub const CONTROL_GROUPS: [&str; 16] = [
22    "oscillator waveform",
23    "distortion drive",
24    "filter cutoff",
25    "noise level",
26    "reverb",
27    "lfo modulation",
28    "delay",
29    "frequency modulation",
30    "unison detune",
31    "envelope",
32    "formant filter",
33    "phaser and flanger",
34    "chorus",
35    "sub oscillator",
36    "granular",
37    "filter resonance",
38];
39
40/// The cards a style word may hint at, besides `pending` and `no hint`.
41const CARDS: [&str; 16] = [
42    "bass",
43    "lead",
44    "pad",
45    "keys",
46    "drums",
47    "fx",
48    "arp",
49    "sequence",
50    "pluck",
51    "chord and stab",
52    "atmosphere",
53    "bell and mallet",
54    "texture and drone",
55    "hit",
56    "chip",
57    "experimental",
58];
59
60#[derive(Deserialize)]
61struct Source {
62    id: String,
63}
64
65/// Every broken rule, as one line each; empty when the data is sound.
66#[must_use]
67pub fn problems(words: &Words) -> Vec<String> {
68    let mut found = Vec::new();
69    let sources: BTreeSet<String> = match serde_json::from_str::<Vec<Source>>(SOURCES) {
70        Ok(list) => list.into_iter().map(|source| source.id).collect(),
71        Err(error) => {
72            found.push(format!("dictionary/sources.json: {error}"));
73            BTreeSet::new()
74        }
75    };
76    found.extend(listener_problems(words));
77    let mut ids = BTreeSet::new();
78    for entry in &words.entries {
79        if !ids.insert(entry.id.as_str()) {
80            found.push(format!("{}: the id is used twice", entry.id));
81        }
82        found.extend(
83            entry_problems(entry, words, &sources)
84                .into_iter()
85                .map(|problem| format!("{}: {problem}", entry.id)),
86        );
87    }
88    found.extend(sense_problems(words));
89    for anchor in &words.ladder.anchors {
90        found.extend(
91            value_problems(&anchor.value, &sources)
92                .into_iter()
93                .map(|problem| format!("ladder {} {}: {problem}", anchor.control, anchor.step)),
94        );
95    }
96    found.extend(list_problems(words));
97    found
98}
99
100fn listener_problems(words: &Words) -> Vec<String> {
101    let mut found = Vec::new();
102    for row in &words.listeners.eq {
103        let word = &row.word;
104        if row.class != Class::of(row.value, row.n) {
105            found.push(format!(
106                "agreement.json {word}: class {:?} does not follow from value {:?} and n {}",
107                row.class, row.value, row.n
108            ));
109        }
110        if row.few_sample != (row.n < KNOWN_FROM) {
111            found.push(format!(
112                "agreement.json {word}: the few-sample flag is wrong"
113            ));
114        }
115        if row.value.is_some() != (row.n >= 2) {
116            found.push(format!(
117                "agreement.json {word}: a value needs two curves or more"
118            ));
119        }
120        if row.n < AGGREGATE_FROM && !words.entries.iter().any(|entry| entry.word == *word) {
121            found.push(format!(
122                "agreement.json {word}: a label of {} curves is kept only for an entry word",
123                row.n
124            ));
125        }
126        let aggregate = row.bands.is_some() || row.peak_hz.is_some();
127        if aggregate && row.n < AGGREGATE_FROM {
128            found.push(format!(
129                "agreement.json {word}: band means from {} curves come too close to one curve",
130                row.n
131            ));
132        }
133    }
134    for row in &words.listeners.reverb {
135        if row.n < AGGREGATE_FROM {
136            found.push(format!(
137                "agreement.json reverb {}: {} settings come too close to one row",
138                row.word, row.n
139            ));
140        }
141    }
142    found
143}
144
145/// The count of sources a tag allows, over the sources that are not search
146/// excerpts, and whether the sources must all be search excerpts.
147fn tag_problem(tag: Tag, sources: &[String]) -> Option<&'static str> {
148    if sources.iter().collect::<BTreeSet<_>>().len() < sources.len() {
149        return Some("a source is listed twice");
150    }
151    // The listener data is derived, so it states nothing and never counts
152    // toward `source states` or `single source`.
153    let pages = sources
154        .iter()
155        .filter(|id| !id.starts_with("snip-") && *id != "crowd-eq")
156        .count();
157    let ok = match tag {
158        Tag::SourceStates => pages >= 2,
159        Tag::SingleSource => pages == 1,
160        Tag::Snippet => pages == 0 && sources.iter().any(|id| id.starts_with("snip-")),
161        Tag::CrowdData => sources == ["crowd-eq"],
162        Tag::Inferred => sources.is_empty(),
163    };
164    (!ok).then_some(match tag {
165        Tag::SourceStates => "`source states` needs two sources or more",
166        Tag::SingleSource => "`single source` needs exactly one source besides search excerpts",
167        Tag::Snippet => "`snippet` cites search excerpts only",
168        Tag::CrowdData => "`crowd data` cites the listener data alone",
169        Tag::Inferred => "`inferred` cites no source",
170    })
171}
172
173fn value_problems(value: &Value, sources: &BTreeSet<String>) -> Vec<String> {
174    let mut found = Vec::new();
175    if value.tag == Tag::CrowdData || value.tag == Tag::Inferred {
176        found.push(format!("{}: a number needs a source tag", value.what));
177    } else if let Some(problem) = tag_problem(value.tag, &value.sources) {
178        found.push(format!("{}: {problem}", value.what));
179    }
180    if value.low > value.high {
181        found.push(format!("{}: low lies above high", value.what));
182    }
183    if value.what.chars().any(|c| c.is_ascii_digit()) {
184        found.push(format!("`{}` holds a digit", value.what));
185    }
186    for source in &value.sources {
187        if !sources.contains(source) {
188            found.push(format!(
189                "source `{source}` is not in dictionary/sources.json"
190            ));
191        }
192    }
193    found
194}
195
196fn entry_problems(entry: &Entry, words: &Words, sources: &BTreeSet<String>) -> Vec<String> {
197    let mut found = Vec::new();
198    for name in std::iter::once(&entry.word).chain(&entry.aliases) {
199        if *name != name.to_lowercase() {
200            found.push(format!(
201                "`{name}` is not lowercase, so a lookup never finds it"
202            ));
203        }
204    }
205    if !entry.id.ends_with(&format!(".{}", entry.sense)) {
206        found.push(format!(
207            "the id does not end with the sense `{}`",
208            entry.sense
209        ));
210    }
211    for (field, text) in [
212        ("meaning", Some(&entry.meaning)),
213        ("label", entry.label.as_ref()),
214    ] {
215        if text.is_some_and(|text| text.chars().any(|c| c.is_ascii_digit())) {
216            found.push(format!(
217                "the {field} holds a digit: numbers go into `values`"
218            ));
219        }
220    }
221    if let Some(problem) = tag_problem(entry.tag, &entry.sources) {
222        found.push(problem.to_owned());
223    }
224    if entry.tag == Tag::Inferred && !entry.values.is_empty() {
225        found.push("`inferred` holds no number".to_owned());
226    }
227    for source in &entry.sources {
228        if !sources.contains(source) {
229            found.push(format!(
230                "source `{source}` is not in dictionary/sources.json"
231            ));
232        }
233    }
234    for value in &entry.values {
235        found.extend(value_problems(value, sources));
236    }
237    found.extend(listener_match_problems(entry, words));
238    if let Some(measure) = &entry.measure
239        && (measure.key == "band_share") != measure.band.is_some()
240    {
241        found.push("a measure names a band exactly when it is `band_share`".to_owned());
242    }
243    if entry.control.is_some() != entry.card_hint.is_some() {
244        found.push("`no control` and a card hint go together".to_owned());
245    }
246    if !entry.music.is_empty() {
247        match crate::music::dictionary() {
248            Ok(music) => {
249                for id in &entry.music {
250                    if music.entry(id).is_none() {
251                        found.push(format!("music entry `{id}` does not exist"));
252                    }
253                }
254            }
255            Err(error) => found.push(error.to_string()),
256        }
257    }
258    for conflict in &entry.conflicts {
259        match words.entry(conflict) {
260            None => found.push(format!("conflict `{conflict}` does not exist")),
261            Some(other) if !other.conflicts.contains(&entry.id) => {
262                found.push(format!(
263                    "conflict `{conflict}` does not list this entry back"
264                ));
265            }
266            Some(_) => {}
267        }
268    }
269    found
270}
271
272/// Whether an entry carries the listener numbers of its word.
273fn listener_match_problems(entry: &Entry, words: &Words) -> Vec<String> {
274    let mut found = Vec::new();
275    let word = entry.word.to_lowercase();
276    let row = words.eq(&word);
277    let (want, few) = row.map_or(((None, 0, Class::NoData), false), |row| {
278        ((row.value, row.n, row.class), row.few_sample)
279    });
280    let agreement = &entry.agreement;
281    if (agreement.value, agreement.n, agreement.class) != want || entry.few_sample != few {
282        found.push(format!(
283            "agreement {:?}, n {}, {:?}, few-sample {} differ from agreement.json {want:?}, few-sample {few}",
284            agreement.value, agreement.n, agreement.class, entry.few_sample
285        ));
286    }
287    for alias in &entry.aliases {
288        if words
289            .eq(alias)
290            .is_some_and(|row| matches!(row.class, Class::High | Class::Medium | Class::Low))
291        {
292            found.push(format!(
293                "alias `{alias}` has its own agreement class: give it its own entry"
294            ));
295        }
296    }
297    match (&entry.reverb, words.reverb(&word)) {
298        (Some(stored), Some(row)) => {
299            if (stored.n, stored.spread_ratio, stored.effects)
300                != (row.n, row.spread_ratio, row.effects)
301            {
302                found.push("the reverb numbers differ from agreement.json".to_owned());
303            }
304        }
305        (Some(_), None) => found.push("agreement.json has no reverb row for the word".to_owned()),
306        (None, Some(_)) if entry.axis == Axis::Space => {
307            found.push("a space word with listener reverb numbers must carry them".to_owned());
308        }
309        (None, _) => {}
310    }
311    found
312}
313
314/// One default per word; a word with several senses offers two or three
315/// labelled senses, the default among them.
316fn sense_problems(words: &Words) -> Vec<String> {
317    let mut by_word: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new();
318    for entry in &words.entries {
319        by_word.entry(&entry.word).or_default().push(entry);
320    }
321    let mut found = Vec::new();
322    let mut lead_of: BTreeMap<&str, &str> = BTreeMap::new();
323    for entry in &words.entries {
324        for name in std::iter::once(&entry.word).chain(&entry.aliases) {
325            match lead_of.insert(name, &entry.word) {
326                Some(other) if other != entry.word => found.push(format!(
327                    "`{name}` names both `{other}` and `{}`: keep it on one word",
328                    entry.word
329                )),
330                _ => {}
331            }
332        }
333    }
334    for (word, senses) in by_word {
335        let defaults = senses.iter().filter(|entry| entry.default).count();
336        if defaults != 1 {
337            found.push(format!("{word}: {defaults} default senses, want one"));
338        }
339        let labelled: Vec<&&Entry> = senses
340            .iter()
341            .filter(|entry| entry.label.is_some())
342            .collect();
343        if senses.len() == 1 {
344            if !labelled.is_empty() {
345                found.push(format!("{word}: a single sense needs no label"));
346            }
347        } else if !(2..=3).contains(&labelled.len()) || !labelled.iter().any(|entry| entry.default)
348        {
349            found.push(format!(
350                "{word}: the question needs two or three labelled senses, the default among them"
351            ));
352        }
353    }
354    found
355}
356
357fn list_problems(words: &Words) -> Vec<String> {
358    let mut found = Vec::new();
359    for (name, list) in [("style", &words.style), ("technique", &words.technique)] {
360        for (index, row) in list.iter().enumerate() {
361            if usize::try_from(row.rank).ok() != Some(index + 1) {
362                found.push(format!(
363                    "{name}.json {}: rank {} out of order",
364                    row.word, row.rank
365                ));
366            }
367        }
368    }
369    for row in &words.style {
370        let hint = row.hint.as_deref().unwrap_or("");
371        if row.group.is_some() || !(hint == "pending" || hint == "no hint" || CARDS.contains(&hint))
372        {
373            found.push(format!(
374                "style.json {}: the hint is `pending`, a card or `no hint`, with no group",
375                row.word
376            ));
377        }
378    }
379    let mut groups = BTreeSet::new();
380    for (index, row) in words.technique.iter().enumerate() {
381        let mapped = index < CONTROL_GROUPS.len();
382        match &row.group {
383            Some(group) if mapped && CONTROL_GROUPS.contains(&group.as_str()) => {
384                if !groups.insert(group.as_str()) {
385                    found.push(format!("technique.json: group `{group}` named twice"));
386                }
387            }
388            None if !mapped => {}
389            _ => found.push(format!(
390                "technique.json {}: the first {} words name a control group, the rest none",
391                row.word,
392                CONTROL_GROUPS.len()
393            )),
394        }
395        if row.hint.is_some() {
396            found.push(format!(
397                "technique.json {}: a technique word has no hint",
398                row.word
399            ));
400        }
401    }
402    found
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    fn strings(ids: &[&str]) -> Vec<String> {
410        ids.iter().map(|id| (*id).to_owned()).collect()
411    }
412
413    #[test]
414    fn a_wrong_class_or_flag_in_the_listener_numbers_fails() {
415        let mut words = crate::words::parse().unwrap();
416        assert_eq!(listener_problems(&words), Vec::<String>::new());
417        let dark = words
418            .listeners
419            .eq
420            .iter_mut()
421            .find(|row| row.word == "dark")
422            .unwrap();
423        dark.class = Class::Medium;
424        dark.few_sample = true;
425        let found = listener_problems(&words);
426        assert!(found.iter().any(|p| p.contains("dark: class")), "{found:?}");
427        assert!(
428            found.iter().any(|p| p.contains("dark: the few-sample")),
429            "{found:?}"
430        );
431    }
432
433    #[test]
434    fn listener_numbers_from_too_few_rows_fail() {
435        let mut words = crate::words::parse().unwrap();
436        let few = words
437            .listeners
438            .eq
439            .iter_mut()
440            .find(|row| row.n == 1)
441            .unwrap();
442        few.peak_hz = Some(120);
443        words.listeners.reverb[0].n = 1;
444        let found = listener_problems(&words);
445        assert!(
446            found.iter().any(|p| p.contains("too close to one curve")),
447            "{found:?}"
448        );
449        assert!(
450            found.iter().any(|p| p.contains("too close to one row")),
451            "{found:?}"
452        );
453    }
454
455    #[test]
456    fn a_tag_states_how_many_sources_back_it() {
457        assert!(tag_problem(Tag::SourceStates, &strings(&["a", "b"])).is_none());
458        assert!(tag_problem(Tag::SourceStates, &strings(&["a", "snip-b"])).is_some());
459        assert!(tag_problem(Tag::SingleSource, &strings(&["a", "snip-b"])).is_none());
460        assert!(tag_problem(Tag::SingleSource, &strings(&["a", "b"])).is_some());
461        assert!(tag_problem(Tag::Snippet, &strings(&["snip-a"])).is_none());
462        assert!(tag_problem(Tag::Snippet, &strings(&["snip-a", "b"])).is_some());
463        assert!(tag_problem(Tag::CrowdData, &strings(&["crowd-eq"])).is_none());
464        assert!(tag_problem(Tag::CrowdData, &strings(&["crowd-eq", "b"])).is_some());
465        assert!(tag_problem(Tag::SourceStates, &strings(&["crowd-eq", "a"])).is_some());
466        assert!(tag_problem(Tag::SingleSource, &strings(&["crowd-eq", "a"])).is_none());
467        assert!(tag_problem(Tag::Inferred, &[]).is_none());
468        assert!(tag_problem(Tag::Inferred, &strings(&["a"])).is_some());
469        assert_eq!(
470            tag_problem(Tag::SourceStates, &strings(&["a", "a"])),
471            Some("a source is listed twice")
472        );
473    }
474
475    #[test]
476    fn a_capitalised_word_or_alias_fails() {
477        let mut words = crate::words::parse().unwrap();
478        words.entries[0].word = words.entries[0].word.to_uppercase();
479        let found = problems(&words);
480        assert!(
481            found.iter().any(|p| p.contains("is not lowercase")),
482            "{found:?}"
483        );
484    }
485}