1use 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
16pub const AGGREGATE_FROM: u32 = 5;
19
20pub 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
40const 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#[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
145fn 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 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
272fn 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
314fn 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}