Skip to main content

sound_to_patch/preset/
edits.rs

1//! The edit list: the JSON an agent writes and `preset apply` reads.
2//!
3//! ```json
4//! {"synth": "vital",
5//!  "edits": [
6//!    {"param": "filter_1_cutoff", "value": "410 Hz", "reason": "cutoff measured near 410 Hz"},
7//!    {"route": {"source": "lfo_1", "dest": "filter_1_cutoff", "amount": "25%"}, "reason": "wobble"},
8//!    {"menu": "filter_1_model", "choice": "Ladder", "reason": "warm low-pass"},
9//!    {"raw": {"key": "settings_key_not_in_registry", "value": 1.0}, "reason": "newer build"}],
10//!  "ambiguities": [{"id": "filter_type", "choice": "ladder", "reason": "smooth slope"}]}
11//! ```
12//!
13//! A unit control takes a unit string such as `"1.2 kHz"`, `"800 ms"`,
14//! `"-6 dB"`, `"+12 st"` or `"25%"`, never a bare number. A count takes a
15//! whole number, a switch takes `true` or `false`, and a menu takes the
16//! label of its choice. A control without a physical unit takes its
17//! stored value as a bare number. [`parse`] reads the text strictly and
18//! [`resolve`] checks every edit against the registry. Both report every
19//! problem, not only the first, and each message names the edit, the
20//! cause and the fix.
21
22use std::collections::HashMap;
23use std::error::Error;
24use std::fmt;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::preset::format::{RoutePolarity, Stored};
30use crate::preset::registry::{Law, ParamKind, ParamRegistry, ParamSpec, Physical, UnitKind};
31use crate::synth::Synth;
32use crate::units::{Bipolar, Percent, Semitones};
33
34/// A parsed edit list. It serializes to the same JSON form [`parse`]
35/// reads.
36#[derive(Clone, Debug, PartialEq, Serialize)]
37pub struct EditList {
38    /// The synth the edits are written for.
39    pub synth: Synth,
40    /// The edits, in order.
41    pub edits: Vec<Edit>,
42    /// One decision per ambiguity of the layer report.
43    pub ambiguities: Vec<Decision>,
44}
45
46/// One edit: a record and the reason for it.
47#[derive(Clone, Debug, PartialEq)]
48pub struct Edit {
49    /// What to change.
50    pub record: Record,
51    /// Why. The agent path requires it; the report path fills in the layer
52    /// name.
53    pub reason: Option<String>,
54}
55
56/// The four record kinds.
57#[derive(Clone, Debug, PartialEq)]
58pub enum Record {
59    /// A registry control and its value.
60    Param {
61        /// The registry key.
62        param: String,
63        /// The value as written.
64        value: ParamValue,
65    },
66    /// A modulation route.
67    Route(RouteRecord),
68    /// A menu control and the label of its choice.
69    Menu {
70        /// The registry key.
71        menu: String,
72        /// The label of the choice.
73        choice: String,
74    },
75    /// A key the registry does not cover yet, with its value as the file
76    /// stores it.
77    Raw {
78        /// The key.
79        key: String,
80        /// The value.
81        value: Stored,
82    },
83}
84
85/// A value of a parameter record as written.
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(untagged)]
88pub enum ParamValue {
89    /// A switch.
90    Flag(bool),
91    /// A bare number: a count, or the stored value of a control without a
92    /// unit.
93    Number(f64),
94    /// A unit string or a menu label.
95    Text(String),
96}
97
98impl fmt::Display for ParamValue {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::Flag(flag) => write!(f, "{flag}"),
102            Self::Number(number) => write!(f, "{number}"),
103            Self::Text(text) => write!(f, "{text:?}"),
104        }
105    }
106}
107
108/// A route record.
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct RouteRecord {
112    /// The source name.
113    pub source: String,
114    /// The destination key.
115    pub dest: String,
116    /// The depth: a percent of the destination's range, or semitones for a
117    /// destination stored in semitones.
118    pub amount: String,
119    /// The polarity; the adapter picks one by source when it is absent.
120    #[serde(default)]
121    pub polarity: Option<PolarityText>,
122}
123
124/// The polarity as an edit list writes it.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum PolarityText {
128    /// One way from the value.
129    Unipolar,
130    /// Both ways around the value.
131    Bipolar,
132}
133
134impl From<PolarityText> for RoutePolarity {
135    fn from(text: PolarityText) -> Self {
136        match text {
137            PolarityText::Unipolar => Self::Unipolar,
138            PolarityText::Bipolar => Self::Bipolar,
139        }
140    }
141}
142
143/// The decision on one ambiguity of the layer report.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct Decision {
147    /// The ambiguity id from the report.
148    pub id: String,
149    /// The chosen candidate, or `both` to keep both for refinement.
150    pub choice: String,
151    /// Why.
152    pub reason: String,
153}
154
155/// One problem with an edit list.
156#[derive(Clone, Debug, PartialEq, Eq)]
157pub struct EditError {
158    /// Where: `edit 3 (filter_1_cutoff)` or `the edit list`.
159    pub place: String,
160    /// What is wrong.
161    pub cause: String,
162    /// How to fix it.
163    pub fix: String,
164}
165
166impl fmt::Display for EditError {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        write!(f, "{}: {}. {}", self.place, self.cause, self.fix)
169    }
170}
171
172impl Error for EditError {}
173
174/// Every problem of an edit list, in order.
175#[derive(Clone, Debug, PartialEq, Eq)]
176pub struct EditErrors(pub Vec<EditError>);
177
178impl fmt::Display for EditErrors {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        write!(f, "the edit list has {} problem(s):", self.0.len())?;
181        for error in &self.0 {
182            write!(f, "\n  {error}")?;
183        }
184        Ok(())
185    }
186}
187
188impl Error for EditErrors {}
189
190fn place(index: usize, name: &str) -> String {
191    format!("edit {} ({name})", index + 1)
192}
193
194/// The most problems [`parse`] and [`resolve`] report. A real list has a
195/// few; a list with thousands of bad edits stops early.
196const MAX_PROBLEMS: usize = 50;
197
198/// The last entry of a report that stopped at [`MAX_PROBLEMS`].
199fn stopped() -> EditError {
200    EditError {
201        place: "the edit list".to_owned(),
202        cause: format!("the check stopped after {MAX_PROBLEMS} problems"),
203        fix: "Fix these and run the command again".to_owned(),
204    }
205}
206
207#[derive(Deserialize)]
208#[serde(deny_unknown_fields)]
209struct ParamData {
210    param: String,
211    value: ParamValue,
212    #[serde(default)]
213    reason: Option<String>,
214    /// A layer report's per-edit fields, read and dropped: the applier
215    /// takes the provenance from the registry.
216    #[serde(default, rename = "uncalibrated")]
217    _uncalibrated: Option<bool>,
218    #[serde(default, rename = "confidence")]
219    _confidence: Option<crate::confidence::Level>,
220}
221
222#[derive(Deserialize)]
223#[serde(deny_unknown_fields)]
224struct RouteData {
225    route: RouteRecord,
226    #[serde(default)]
227    reason: Option<String>,
228    /// A layer report's per-edit fields, read and dropped: the applier
229    /// takes the provenance from the registry.
230    #[serde(default, rename = "uncalibrated")]
231    _uncalibrated: Option<bool>,
232    #[serde(default, rename = "confidence")]
233    _confidence: Option<crate::confidence::Level>,
234}
235
236#[derive(Deserialize)]
237#[serde(deny_unknown_fields)]
238struct MenuData {
239    menu: String,
240    choice: String,
241    #[serde(default)]
242    reason: Option<String>,
243}
244
245#[derive(Deserialize)]
246#[serde(deny_unknown_fields)]
247struct RawData {
248    raw: RawValue,
249    #[serde(default)]
250    reason: Option<String>,
251}
252
253#[derive(Deserialize)]
254#[serde(deny_unknown_fields)]
255struct RawValue {
256    key: String,
257    value: Value,
258}
259
260#[derive(Deserialize)]
261#[serde(deny_unknown_fields)]
262struct ListData {
263    synth: Synth,
264    edits: Vec<Value>,
265    #[serde(default)]
266    ambiguities: Vec<Decision>,
267}
268
269/// Whether every edit must carry a reason.
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub enum Path {
272    /// The agent writes the list: each edit needs a reason.
273    Agent,
274    /// The list comes from a layer report, which supplies the reasons.
275    Report,
276}
277
278/// Reads an edit list. A leading UTF-8 byte order mark, which some
279/// editors add, is skipped.
280///
281/// # Errors
282///
283/// [`EditErrors`] with one entry per bad record, up to 50: unknown
284/// fields, a record of no known kind, a wrong value type or, on the agent
285/// path, a missing or empty reason.
286pub fn parse(text: &str, path: Path) -> Result<EditList, EditErrors> {
287    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
288    let data: ListData = serde_json::from_str(text).map_err(|error| {
289        EditErrors(vec![EditError {
290            place: "the edit list".to_owned(),
291            cause: format!("it does not read: {error}"),
292            fix: "Write one JSON object with `synth`, `edits` and an optional `ambiguities` list"
293                .to_owned(),
294        }])
295    })?;
296    let mut edits = Vec::new();
297    let mut errors = Vec::new();
298    for (index, value) in data.edits.into_iter().enumerate() {
299        if errors.len() >= MAX_PROBLEMS {
300            errors.push(stopped());
301            break;
302        }
303        match parse_edit(&value) {
304            Ok(edit) => {
305                if path == Path::Agent && edit.reason.as_deref().is_none_or(|r| r.trim().is_empty())
306                {
307                    errors.push(EditError {
308                        place: place(index, edit.record.name()),
309                        cause: "the edit has no reason".to_owned(),
310                        fix: "Add a `reason` that says what was heard or measured".to_owned(),
311                    });
312                }
313                edits.push(edit);
314            }
315            Err((name, cause, fix)) => errors.push(EditError {
316                place: place(index, &name),
317                cause,
318                fix,
319            }),
320        }
321    }
322    if errors.is_empty() {
323        Ok(EditList {
324            synth: data.synth,
325            edits,
326            ambiguities: data.ambiguities,
327        })
328    } else {
329        Err(EditErrors(errors))
330    }
331}
332
333type RecordError = (String, String, String);
334
335fn parse_edit(value: &Value) -> Result<Edit, RecordError> {
336    let kinds = ["param", "route", "menu", "raw"];
337    let Value::Object(map) = value else {
338        return Err((
339            "?".to_owned(),
340            format!("the edit is {value}, not an object"),
341            "Write an object with one of `param`, `route`, `menu` or `raw`".to_owned(),
342        ));
343    };
344    let found: Vec<&str> = kinds
345        .into_iter()
346        .filter(|kind| map.contains_key(*kind))
347        .collect();
348    let name = map
349        .get("param")
350        .or_else(|| map.get("menu"))
351        .and_then(Value::as_str)
352        .or_else(|| {
353            map.get("raw")
354                .and_then(|raw| raw.get("key"))
355                .and_then(Value::as_str)
356        })
357        .unwrap_or_else(|| found.first().copied().unwrap_or("?"))
358        .to_owned();
359    let invalid = |error: serde_json::Error, example: &str| {
360        (
361            name.clone(),
362            format!("the record does not read: {error}"),
363            format!("Write it as {example}"),
364        )
365    };
366    match found.as_slice() {
367        ["param"] => serde_json::from_value::<ParamData>(value.clone())
368            .map(|data| Edit {
369                record: Record::Param {
370                    param: data.param,
371                    value: data.value,
372                },
373                reason: data.reason,
374            })
375            .map_err(|error| invalid(error, r#"{"param": "<key>", "value": "410 Hz", "reason": "..."}"#)),
376        ["route"] => serde_json::from_value::<RouteData>(value.clone())
377            .map(|data| Edit {
378                record: Record::Route(data.route),
379                reason: data.reason,
380            })
381            .map_err(|error| {
382                invalid(
383                    error,
384                    r#"{"route": {"source": "lfo_1", "dest": "<key>", "amount": "25%", "polarity": "bipolar"}, "reason": "..."}"#,
385                )
386            }),
387        ["menu"] => serde_json::from_value::<MenuData>(value.clone())
388            .map(|data| Edit {
389                record: Record::Menu {
390                    menu: data.menu,
391                    choice: data.choice,
392                },
393                reason: data.reason,
394            })
395            .map_err(|error| invalid(error, r#"{"menu": "<key>", "choice": "<label>", "reason": "..."}"#)),
396        ["raw"] => {
397            let data = serde_json::from_value::<RawData>(value.clone())
398                .map_err(|error| invalid(error, r#"{"raw": {"key": "<key>", "value": 1.0}, "reason": "..."}"#))?;
399            let stored = stored_of(&data.raw.value).ok_or_else(|| {
400                (
401                    name.clone(),
402                    format!("the raw value {} is not a number, a flag or a text", data.raw.value),
403                    "Write the value as the file stores it".to_owned(),
404                )
405            })?;
406            Ok(Edit {
407                record: Record::Raw {
408                    key: data.raw.key,
409                    value: stored,
410                },
411                reason: data.reason,
412            })
413        }
414        [] => Err((
415            name,
416            "the edit has no record kind".to_owned(),
417            "Add one of `param`, `route`, `menu` or `raw`".to_owned(),
418        )),
419        _ => Err((
420            name,
421            format!("the edit mixes record kinds: {}", found.join(", ")),
422            "Write one record kind per edit".to_owned(),
423        )),
424    }
425}
426
427fn stored_of(value: &Value) -> Option<Stored> {
428    match value {
429        Value::Bool(flag) => Some(Stored::Flag(*flag)),
430        Value::String(text) => Some(Stored::Text(text.clone())),
431        Value::Number(number) => match (number.as_i64(), number.as_f64()) {
432            (Some(int), _) if !number.is_f64() => Some(Stored::Int(int)),
433            (_, Some(float)) => Some(Stored::Float(float)),
434            _ => None,
435        },
436        Value::Null | Value::Array(_) | Value::Object(_) => None,
437    }
438}
439
440impl Serialize for Edit {
441    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
442        let mut map = serde_json::Map::new();
443        match &self.record {
444            Record::Param { param, value: v } => {
445                map.insert("param".into(), Value::String(param.clone()));
446                map.insert(
447                    "value".into(),
448                    serde_json::to_value(v).map_err(serde::ser::Error::custom)?,
449                );
450            }
451            Record::Route(route) => {
452                map.insert(
453                    "route".into(),
454                    serde_json::to_value(route).map_err(serde::ser::Error::custom)?,
455                );
456            }
457            Record::Menu { menu, choice } => {
458                map.insert("menu".into(), Value::String(menu.clone()));
459                map.insert("choice".into(), Value::String(choice.clone()));
460            }
461            Record::Raw { key, value: v } => {
462                let raw = match v {
463                    Stored::Float(number) => serde_json::json!(number),
464                    Stored::Int(number) => serde_json::json!(number),
465                    Stored::Flag(flag) => Value::Bool(*flag),
466                    Stored::Text(text) => Value::String(text.clone()),
467                };
468                map.insert("raw".into(), serde_json::json!({"key": key, "value": raw}));
469            }
470        }
471        if let Some(reason) = &self.reason {
472            map.insert("reason".into(), Value::String(reason.clone()));
473        }
474        map.serialize(serializer)
475    }
476}
477
478impl Record {
479    /// The key, menu or route destination the record names.
480    #[must_use]
481    pub fn name(&self) -> &str {
482        match self {
483            Self::Param { param, .. } => param,
484            Self::Route(route) => &route.dest,
485            Self::Menu { menu, .. } => menu,
486            Self::Raw { key, .. } => key,
487        }
488    }
489}
490
491/// An edit checked against the registry, with values in stored units.
492#[derive(Clone, Debug, PartialEq)]
493pub enum Resolved {
494    /// A control and its stored value.
495    Param {
496        /// The registry key.
497        key: String,
498        /// The stored value, inside the registry range.
499        stored: f64,
500        /// The registry law is interim: the edit is uncalibrated.
501        uncalibrated: bool,
502        /// Why.
503        reason: Option<String>,
504    },
505    /// A route with its amount as the file stores it.
506    Route {
507        /// The source name.
508        source: String,
509        /// The destination key.
510        destination: String,
511        /// The amount, -1 to 1 of the destination's stored range.
512        amount: f64,
513        /// The polarity, when the edit gives one.
514        polarity: Option<RoutePolarity>,
515        /// Why.
516        reason: Option<String>,
517    },
518    /// A key the registry does not cover.
519    Raw {
520        /// The key.
521        key: String,
522        /// The value.
523        value: Stored,
524        /// Why.
525        reason: Option<String>,
526    },
527}
528
529/// Checks every edit against the registry of the list's synth.
530///
531/// # Errors
532///
533/// [`EditErrors`] with one entry per edit that fails, up to 50: the list
534/// is for another synth, an unknown key (with up to ten close names), a
535/// wrong value kind, a unit string that does not parse, a value out of
536/// range (with the range, the unit and the nearest valid value), an
537/// unknown menu label, a raw key the registry covers, or a second edit of
538/// one control or of one route's source and destination (with the number
539/// of the first).
540pub fn resolve(list: &EditList, registry: &dyn ParamRegistry) -> Result<Vec<Resolved>, EditErrors> {
541    if list.synth != registry.synth() {
542        return Err(EditErrors(vec![EditError {
543            place: "the edit list".to_owned(),
544            cause: format!(
545                "it is written for {}, but the target is {}",
546                list.synth.name(),
547                registry.synth().name()
548            ),
549            fix: format!(
550                "Pass --synth {}, or write the list for {}",
551                list.synth,
552                registry.synth().name()
553            ),
554        }]));
555    }
556    let mut resolved = Vec::new();
557    let mut errors = Vec::new();
558    // The first edit of each control and of each route's source and
559    // destination: a second one would override it in silence.
560    let mut first: HashMap<(String, String), usize> = HashMap::new();
561    for (index, edit) in list.edits.iter().enumerate() {
562        if errors.len() >= MAX_PROBLEMS {
563            errors.push(stopped());
564            break;
565        }
566        let name = edit.record.name();
567        match resolve_record(&edit.record, registry) {
568            Ok(mut item) => {
569                match &mut item {
570                    Resolved::Param { reason, .. }
571                    | Resolved::Route { reason, .. }
572                    | Resolved::Raw { reason, .. } => reason.clone_from(&edit.reason),
573                }
574                let (target, what) = match &item {
575                    Resolved::Param { key, .. } | Resolved::Raw { key, .. } => {
576                        ((key.clone(), String::new()), format!("{key} again"))
577                    }
578                    Resolved::Route {
579                        source,
580                        destination,
581                        ..
582                    } => (
583                        (source.clone(), destination.clone()),
584                        format!("the route {source} -> {destination} again"),
585                    ),
586                };
587                if let Some(earlier) = first.get(&target) {
588                    errors.push(EditError {
589                        place: place(index, name),
590                        cause: format!("it edits {what}, after edit {}", earlier + 1),
591                        fix: "Keep one of the two edits; the second would override the first"
592                            .to_owned(),
593                    });
594                    continue;
595                }
596                first.insert(target, index);
597                resolved.push(item);
598            }
599            Err((cause, fix)) => errors.push(EditError {
600                place: place(index, name),
601                cause,
602                fix,
603            }),
604        }
605    }
606    if errors.is_empty() {
607        Ok(resolved)
608    } else {
609        Err(EditErrors(errors))
610    }
611}
612
613type Problem = (String, String);
614
615fn spec_of<'a>(registry: &'a dyn ParamRegistry, key: &str) -> Result<&'a ParamSpec, Problem> {
616    let spec = registry.get(key).ok_or_else(|| {
617        let close = registry.close_names(key, 10);
618        (
619            format!("{key:?} is not a control of {}", registry.synth().name()),
620            if close.is_empty() {
621                "Check the key against the synth reference".to_owned()
622            } else {
623                format!("Close names: {}", close.join(", "))
624            },
625        )
626    })?;
627    if spec.legacy {
628        return Err((
629            format!("{key} is a legacy control that no module of the synth creates"),
630            "Leave it out; writers never create legacy controls".to_owned(),
631        ));
632    }
633    Ok(spec)
634}
635
636pub(crate) fn resolve_record(
637    record: &Record,
638    registry: &dyn ParamRegistry,
639) -> Result<Resolved, Problem> {
640    match record {
641        Record::Param { param, value } => {
642            let spec = spec_of(registry, param)?;
643            let stored = param_stored(spec, value)?;
644            Ok(Resolved::Param {
645                key: spec.key.clone(),
646                stored,
647                uncalibrated: spec.provenance == crate::preset::registry::Provenance::Interim,
648                reason: None,
649            })
650        }
651        Record::Menu { menu, choice } => {
652            let spec = spec_of(registry, menu)?;
653            if spec.kind != ParamKind::Choice {
654                return Err((
655                    format!("{menu} is not a menu"),
656                    "Use a `param` record for it".to_owned(),
657                ));
658            }
659            Ok(Resolved::Param {
660                key: spec.key.clone(),
661                stored: choice_stored(spec, choice)?,
662                uncalibrated: false,
663                reason: None,
664            })
665        }
666        Record::Route(route) => {
667            let spec = spec_of(registry, &route.dest)?;
668            Ok(Resolved::Route {
669                source: route.source.clone(),
670                destination: spec.key.clone(),
671                amount: route_amount(spec, &route.amount)?,
672                polarity: route.polarity.map(RoutePolarity::from),
673                reason: None,
674            })
675        }
676        Record::Raw { key, value } => {
677            if registry.get(key).is_some() {
678                return Err((
679                    format!("{key} is a registry control, not a raw key"),
680                    "Use a `param` or `menu` record for it".to_owned(),
681                ));
682            }
683            Ok(Resolved::Raw {
684                key: key.clone(),
685                value: value.clone(),
686                reason: None,
687            })
688        }
689    }
690}
691
692/// An example unit string for the fix text of a unit.
693fn example(unit: UnitKind) -> &'static str {
694    match unit {
695        UnitKind::Hz => "\"1.2 kHz\"",
696        UnitKind::Seconds => "\"800 ms\"",
697        UnitKind::Semitones => "\"+12 st\"",
698        UnitKind::Cents => "\"7 ct\"",
699        UnitKind::Octaves => "\"-1 oct\"",
700        UnitKind::Decibels => "\"-6 dB\"",
701        UnitKind::Normalized => "a number from 0 to 1",
702        UnitKind::MidiPitch => "\"45 midi\"",
703        UnitKind::PercentUnipolar => "\"25%\"",
704        UnitKind::PercentBipolar => "\"-40%\"",
705    }
706}
707
708fn param_stored(spec: &ParamSpec, value: &ParamValue) -> Result<f64, Problem> {
709    let key = &spec.key;
710    match (spec.kind, spec.unit(), value) {
711        (ParamKind::Choice, _, ParamValue::Text(label)) => choice_stored(spec, label),
712        // A stored index selects a choice whose label repeats.
713        (ParamKind::Choice, _, ParamValue::Number(number)) if spec.accepts(*number) => Ok(*number),
714        (ParamKind::Choice, _, _) => Err((
715            format!("{key} is a menu, and {value} is not one of its labels or indices"),
716            format!(
717                "Write one of: {}, or a whole stored index from {}",
718                spec.choices.join(", "),
719                spec.range_text()
720            ),
721        )),
722        (ParamKind::Flag, _, ParamValue::Flag(flag)) => Ok(if *flag { 1.0 } else { 0.0 }),
723        (ParamKind::Flag, _, _) => Err((
724            format!("{key} is a switch, and {value} is not true or false"),
725            "Write true or false".to_owned(),
726        )),
727        (_, Some(unit), ParamValue::Text(text)) => {
728            let physical = Physical::parse(unit, text).map_err(|error| {
729                let example = example(unit);
730                (
731                    format!("{key}: {}", error.reason()),
732                    error.hint().map_or_else(
733                        || format!("Write a value such as {example}"),
734                        |hint| format!("{hint}, such as {example}"),
735                    ),
736                )
737            })?;
738            spec.stored(&physical).map_err(|error| {
739                (
740                    error.to_string(),
741                    format!("The control takes {}", unit.type_name()),
742                )
743            })
744        }
745        (_, Some(unit), _) => Err((
746            format!("{key} takes a unit string, not {value}"),
747            format!("Write a value with its unit, such as {}", example(unit)),
748        )),
749        (_, None, ParamValue::Number(number)) => {
750            if spec.accepts(*number) {
751                return Ok(*number);
752            }
753            let whole = spec.kind != ParamKind::Float;
754            if whole && (spec.stored_min..=spec.stored_max).contains(number) {
755                return Err((
756                    format!("{key}: {number} is not a whole number"),
757                    format!("Use a whole number; the nearest is {:?}", number.round()),
758                ));
759            }
760            let nearest = number.clamp(spec.stored_min, spec.stored_max);
761            let nearest = if whole { nearest.round() } else { nearest };
762            Err((
763                format!(
764                    "{key}: {number} is outside the stored range {}",
765                    spec.range_text()
766                ),
767                format!(
768                    "Use a {} from {}; the nearest is {nearest:?}",
769                    if whole { "whole number" } else { "number" },
770                    spec.range_text()
771                ),
772            ))
773        }
774        (_, None, _) => Err((
775            format!("{key} has no physical unit, and {value} is not a number"),
776            format!(
777                "Write the stored value as a bare number from {}",
778                spec.range_text()
779            ),
780        )),
781    }
782}
783
784fn choice_stored(spec: &ParamSpec, label: &str) -> Result<f64, Problem> {
785    let matches: Vec<usize> = spec
786        .choices
787        .iter()
788        .enumerate()
789        .filter(|(_, choice)| *choice == label)
790        .map(|(index, _)| index)
791        .collect();
792    match matches.as_slice() {
793        [index] => Ok(spec.stored_min + crate::cast::usize_to_f64(*index)),
794        [] => Err((
795            format!("{label:?} is not a choice of {}", spec.key),
796            format!("Write one of: {}", spec.choices.join(", ")),
797        )),
798        _ => Err((
799            format!("{label:?} names several choices of {}", spec.key),
800            "Write the stored index as a `param` record; the labels repeat".to_owned(),
801        )),
802    }
803}
804
805/// Whether the stored value of a control is a note number or a semitone
806/// count, so that a semitone amount is a stored offset.
807pub(crate) fn stored_in_semitones(spec: &ParamSpec) -> bool {
808    match spec.law.as_ref().map(|law| (&law.law, law.unit)) {
809        Some((Law::Exp2 { rate, .. }, UnitKind::Hz)) => (rate * 12.0 - 1.0).abs() < 1e-12,
810        Some((Law::Affine { scale, offset }, UnitKind::Semitones)) => {
811            (scale - 1.0).abs() < 1e-12 && offset.abs() < 1e-12
812        }
813        _ => false,
814    }
815}
816
817fn route_amount(spec: &ParamSpec, text: &str) -> Result<f64, Problem> {
818    let span = spec.stored_max - spec.stored_min;
819    let semitones_ok = stored_in_semitones(spec);
820    let fix = if semitones_ok {
821        "Write the depth as a percent of the range, such as \"25%\", or in semitones, such as \"+12 st\""
822    } else {
823        "Write the depth as a percent of the destination's range, such as \"25%\""
824    };
825    let beyond = || {
826        (
827            format!(
828                "route amount {text:?} is beyond the full range of {}",
829                spec.key
830            ),
831            format!(
832                "Keep it within -100% to 100%{}",
833                if semitones_ok {
834                    format!(", or ±{span} st")
835                } else {
836                    String::new()
837                }
838            ),
839        )
840    };
841    let amount = match text.parse::<Percent<Bipolar>>() {
842        Ok(percent) => percent.get() / 100.0,
843        // Only a range error has a source: the text reads as a percent
844        // past ±100%.
845        Err(error) if error.source().is_some() => return Err(beyond()),
846        Err(_) => match (semitones_ok, text.parse::<Semitones>()) {
847            (true, Ok(semitones)) => semitones.get() / span,
848            _ => {
849                return Err((
850                    format!("route amount {text:?} into {} does not read", spec.key),
851                    fix.to_owned(),
852                ));
853            }
854        },
855    };
856    if (-1.0..=1.0).contains(&amount) {
857        Ok(amount)
858    } else {
859        Err(beyond())
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use crate::preset::registry::registry_for;
867    use approx::assert_relative_eq;
868
869    fn vital() -> &'static dyn ParamRegistry {
870        registry_for(Synth::Vital).unwrap()
871    }
872
873    fn list(edits: &str) -> String {
874        format!(r#"{{"synth":"vital","edits":[{edits}]}}"#)
875    }
876
877    #[test]
878    fn a_report_record_keeps_its_mapper_fields_out_of_the_edit() {
879        let report = list(
880            r#"{"param":"portamento_time","value":"0.12 s","reason":"pitch","uncalibrated":false,"confidence":"high"},
881               {"route":{"source":"env_2","dest":"osc_1_transpose","amount":"7 st","polarity":"unipolar"},"reason":"pitch","uncalibrated":true,"confidence":"low"}"#,
882        );
883        let parsed = parse(&report, Path::Report).unwrap();
884        let plain = list(
885            r#"{"param":"portamento_time","value":"0.12 s","reason":"pitch"},
886               {"route":{"source":"env_2","dest":"osc_1_transpose","amount":"7 st","polarity":"unipolar"},"reason":"pitch"}"#,
887        );
888        assert_eq!(parsed, parse(&plain, Path::Report).unwrap());
889        let wrong = list(r#"{"param":"portamento_time","value":"0.12 s","confidence":"huge"}"#);
890        assert!(parse(&wrong, Path::Report).is_err());
891    }
892
893    fn resolve_one(edit: &str) -> Result<Resolved, String> {
894        let parsed = parse(&list(edit), Path::Agent).map_err(|e| e.to_string())?;
895        resolve(&parsed, vital())
896            .map(|mut all| all.remove(0))
897            .map_err(|e| e.to_string())
898    }
899
900    fn stored(edit: &str) -> f64 {
901        match resolve_one(edit).unwrap() {
902            Resolved::Param { stored, .. } => stored,
903            other => panic!("{other:?}"),
904        }
905    }
906
907    #[test]
908    fn each_unit_family_parses_to_its_stored_value() {
909        // Hz -> note number: 12 * log2(1200 / 8.1757989156)
910        assert_relative_eq!(
911            stored(r#"{"param":"filter_1_cutoff","value":"1.2 kHz","reason":"r"}"#),
912            12.0 * (1200.0f64 / 8.175_798_915_6).log2(),
913            max_relative = 1e-12
914        );
915        // seconds -> fourth root: 0.8^(1/4)
916        assert_relative_eq!(
917            stored(r#"{"param":"env_1_attack","value":"800 ms","reason":"r"}"#),
918            0.8f64.powf(0.25),
919            max_relative = 1e-12
920        );
921        // percent -> stored / 100
922        assert_relative_eq!(
923            stored(r#"{"param":"osc_1_pan","value":"-40%","reason":"r"}"#),
924            -0.4,
925            max_relative = 1e-12
926        );
927        // dB -> 10^(dB / 40)
928        assert_relative_eq!(
929            stored(r#"{"param":"osc_1_level","value":"-6 dB","reason":"r"}"#),
930            10f64.powf(-6.0 / 40.0),
931            max_relative = 1e-12
932        );
933        assert_relative_eq!(
934            stored(r#"{"param":"osc_1_transpose","value":"+12 st","reason":"r"}"#),
935            12.0,
936            max_relative = 1e-12
937        );
938        assert_relative_eq!(
939            stored(r#"{"param":"osc_1_tune","value":"7 ct","reason":"r"}"#),
940            0.07,
941            max_relative = 1e-12
942        );
943        assert_relative_eq!(
944            stored(r#"{"param":"osc_1_unison_voices","value":5,"reason":"r"}"#),
945            5.0,
946            max_relative = 1e-12
947        );
948        assert_relative_eq!(
949            stored(r#"{"param":"filter_1_on","value":true,"reason":"r"}"#),
950            1.0,
951            max_relative = 1e-12
952        );
953        assert_relative_eq!(
954            stored(r#"{"param":"lfo_1_tempo","value":"1/8","reason":"r"}"#),
955            9.0,
956            max_relative = 1e-12
957        );
958        assert_relative_eq!(
959            stored(r#"{"menu":"filter_1_model","choice":"Ladder","reason":"r"}"#),
960            2.0,
961            max_relative = 1e-12
962        );
963        assert_relative_eq!(
964            stored(r#"{"param":"env_1_sustain","value":0.5,"reason":"r"}"#),
965            0.5,
966            max_relative = 1e-12
967        );
968    }
969
970    #[test]
971    fn the_five_failures_name_the_parameter_cause_and_fix() {
972        let bare =
973            resolve_one(r#"{"param":"filter_1_cutoff","value":410,"reason":"r"}"#).unwrap_err();
974        assert!(
975            bare.contains("filter_1_cutoff takes a unit string") && bare.contains("\"1.2 kHz\""),
976            "{bare}"
977        );
978        let family =
979            resolve_one(r#"{"param":"env_1_attack","value":"5 Hz","reason":"r"}"#).unwrap_err();
980        assert!(
981            family.contains("env_1_attack: cannot read \"5 Hz\" as Seconds")
982                && family.contains("s, ms"),
983            "{family}"
984        );
985        let unknown =
986            resolve_one(r#"{"param":"filter_1_cuttoff","value":"5 Hz","reason":"r"}"#).unwrap_err();
987        assert!(
988            unknown.contains("Close names: filter_1_cutoff"),
989            "{unknown}"
990        );
991        let range = resolve_one(r#"{"param":"filter_1_cutoff","value":"30 kHz","reason":"r"}"#)
992            .unwrap_err();
993        // One scale, the unit the value is written in; the stored range
994        // stays out of the message.
995        assert!(
996            range.contains(
997                "filter_1_cutoff: 30000 Hz is outside 12.978271799303903 Hz to 21096.16364231088 Hz. Use the nearest, 21096.16364231088 Hz"
998            ) && !range.contains("stored")
999                && !range.contains("136"),
1000            "{range}"
1001        );
1002        let reason = parse(
1003            &list(r#"{"param":"filter_1_cutoff","value":"1 kHz"}"#),
1004            Path::Agent,
1005        )
1006        .unwrap_err()
1007        .to_string();
1008        assert!(
1009            reason.contains("edit 1 (filter_1_cutoff): the edit has no reason"),
1010            "{reason}"
1011        );
1012        assert!(
1013            parse(
1014                &list(r#"{"param":"filter_1_cutoff","value":"1 kHz"}"#),
1015                Path::Report
1016            )
1017            .is_ok()
1018        );
1019    }
1020
1021    #[test]
1022    fn a_wrong_suffix_lists_the_accepted_ones() {
1023        let error =
1024            resolve_one(r#"{"param":"filter_1_cutoff","value":"1 GHz","reason":"r"}"#).unwrap_err();
1025        assert!(error.contains("Hz, kHz"), "{error}");
1026        let text = list(r#"{"param":"filter_1_cutoff","value":"1 GHz","reason":"r"}"#);
1027        let errors = resolve(&parse(&text, Path::Agent).unwrap(), vital()).unwrap_err();
1028        assert_eq!(
1029            errors.0[0].cause,
1030            "filter_1_cutoff: cannot read \"1 GHz\" as Hz: unknown suffix \"GHz\""
1031        );
1032        assert!(
1033            errors.0[0]
1034                .fix
1035                .starts_with("Write a number followed by one of: Hz, kHz, such as"),
1036            "{}",
1037            errors.0[0].fix
1038        );
1039    }
1040
1041    #[test]
1042    fn a_route_depth_in_semitones_divides_by_the_destination_span() {
1043        // Vital 1.0.7 stored 0.5 for a depth of 64 typed into a route to
1044        // the cutoff, whose stored range 8 to 136 spans 128 semitones.
1045        match resolve_one(
1046            r#"{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"+64 st"},"reason":"r"}"#,
1047        )
1048        .unwrap()
1049        {
1050            Resolved::Route { amount, .. } => assert_eq!(amount, 0.5),
1051            other => panic!("{other:?}"),
1052        }
1053    }
1054
1055    #[test]
1056    fn a_percent_route_depth_past_the_full_range_names_the_range() {
1057        for amount in ["150%", "-150%"] {
1058            for dest in ["filter_1_cutoff", "osc_1_level"] {
1059                let error = resolve_one(&format!(
1060                    r#"{{"route":{{"source":"lfo_1","dest":"{dest}","amount":"{amount}"}},"reason":"r"}}"#
1061                ))
1062                .unwrap_err();
1063                assert!(
1064                    error.contains(&format!(
1065                        "route amount \"{amount}\" is beyond the full range of {dest}. Keep it within -100% to 100%"
1066                    )),
1067                    "{error}"
1068                );
1069            }
1070        }
1071        let error = resolve_one(
1072            r#"{"route":{"source":"lfo_1","dest":"osc_1_level","amount":"lots"},"reason":"r"}"#,
1073        )
1074        .unwrap_err();
1075        assert!(error.contains("does not read"), "{error}");
1076    }
1077
1078    #[test]
1079    fn a_second_edit_of_one_target_names_the_first() {
1080        for (edits, needle) in [
1081            (
1082                r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"},{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"}"#,
1083                "edit 2 (filter_1_cutoff): it edits filter_1_cutoff again, after edit 1",
1084            ),
1085            (
1086                r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"},{"param":"env_1_attack","value":"1 s","reason":"r"},{"param":"filter_1_cutoff","value":"900 Hz","reason":"r"}"#,
1087                "edit 3 (filter_1_cutoff): it edits filter_1_cutoff again, after edit 1",
1088            ),
1089            (
1090                r#"{"menu":"filter_1_model","choice":"Ladder","reason":"r"},{"param":"filter_1_model","value":1,"reason":"r"}"#,
1091                "edit 2 (filter_1_model): it edits filter_1_model again, after edit 1",
1092            ),
1093            (
1094                r#"{"raw":{"key":"k","value":1},"reason":"r"},{"raw":{"key":"k","value":2},"reason":"r"}"#,
1095                "edit 2 (k): it edits k again, after edit 1",
1096            ),
1097            (
1098                r#"{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"25%"},"reason":"r"},{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"50%"},"reason":"r"}"#,
1099                "edit 2 (filter_1_cutoff): it edits the route lfo_1 -> filter_1_cutoff again, after edit 1",
1100            ),
1101        ] {
1102            let errors = resolve(&parse(&list(edits), Path::Agent).unwrap(), vital())
1103                .unwrap_err()
1104                .to_string();
1105            assert!(errors.contains(needle), "{needle}\n{errors}");
1106            assert!(errors.contains("has 1 problem(s)"), "{errors}");
1107        }
1108        // A route into a control is no second edit of the control, and
1109        // another source is another route.
1110        let text = list(
1111            r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"},{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"25%"},"reason":"r"},{"route":{"source":"lfo_2","dest":"filter_1_cutoff","amount":"25%"},"reason":"r"}"#,
1112        );
1113        assert_eq!(
1114            resolve(&parse(&text, Path::Agent).unwrap(), vital())
1115                .unwrap()
1116                .len(),
1117            3
1118        );
1119    }
1120
1121    #[test]
1122    fn a_list_with_a_byte_order_mark_reads() {
1123        let text = list(r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"}"#);
1124        assert_eq!(
1125            parse(&format!("\u{feff}{text}"), Path::Agent).unwrap(),
1126            parse(&text, Path::Agent).unwrap()
1127        );
1128    }
1129
1130    #[test]
1131    fn a_list_of_many_bad_edits_stops_at_fifty_problems() {
1132        let bad = vec![r#"{"param":"a","value":1,"reason":"r"}"#; 200].join(",");
1133        let errors = resolve(&parse(&list(&bad), Path::Agent).unwrap(), vital()).unwrap_err();
1134        assert_eq!(errors.0.len(), MAX_PROBLEMS + 1);
1135        assert_eq!(errors.0[MAX_PROBLEMS], stopped());
1136        let unreadable = vec![r#"{"param":"a"}"#; 200].join(",");
1137        let errors = parse(&list(&unreadable), Path::Agent).unwrap_err();
1138        assert_eq!(errors.0.len(), MAX_PROBLEMS + 1);
1139        assert_eq!(errors.0[MAX_PROBLEMS], stopped());
1140        // Fifty problems in fifty edits end the report without the stop.
1141        let fifty = vec![r#"{"param":"a","value":1,"reason":"r"}"#; 50].join(",");
1142        let errors = resolve(&parse(&list(&fifty), Path::Agent).unwrap(), vital()).unwrap_err();
1143        assert_eq!(errors.0.len(), MAX_PROBLEMS);
1144    }
1145
1146    #[test]
1147    fn every_problem_is_reported_not_only_the_first() {
1148        let text = list(
1149            r#"{"param":"nope","value":"1 Hz","reason":"r"},{"param":"filter_1_cutoff","value":"1 Hz","reason":"r"},{"menu":"filter_1_model","choice":"Moog","reason":"r"}"#,
1150        );
1151        let errors = resolve(&parse(&text, Path::Agent).unwrap(), vital()).unwrap_err();
1152        assert_eq!(errors.0.len(), 3);
1153        assert!(errors.0[2].fix.contains("Analog, Dirty, Ladder"));
1154    }
1155
1156    #[test]
1157    fn records_reject_unknown_fields_and_mixed_kinds() {
1158        for (edit, needle) in [
1159            (
1160                r#"{"param":"filter_1_cutoff","value":"1 kHz","reason":"r","why":"x"}"#,
1161                "unknown field",
1162            ),
1163            (
1164                r#"{"param":"filter_1_cutoff","menu":"x","reason":"r"}"#,
1165                "mixes record kinds",
1166            ),
1167            (r#"{"reason":"r"}"#, "no record kind"),
1168            ("5", "not an object"),
1169            (
1170                r#"{"raw":{"key":"k","value":null},"reason":"r"}"#,
1171                "not a number",
1172            ),
1173        ] {
1174            let error = parse(&list(edit), Path::Agent).unwrap_err().to_string();
1175            assert!(error.contains(needle), "{needle}: {error}");
1176        }
1177        assert!(parse(r#"{"synth":"vital","edits":[],"extra":1}"#, Path::Agent).is_err());
1178        assert!(parse(r#"{"edits":[]}"#, Path::Agent).is_err());
1179    }
1180
1181    #[test]
1182    fn route_menu_and_raw_records_parse_and_round_trip() {
1183        let text = list(
1184            r#"{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"+32 st","polarity":"unipolar"},"reason":"wobble"},{"menu":"filter_1_model","choice":"Ladder","reason":"warm"},{"raw":{"key":"custom_warps_count","value":2},"reason":"newer build"}"#,
1185        );
1186        let parsed = parse(&text, Path::Agent).unwrap();
1187        let resolved = resolve(&parsed, vital()).unwrap();
1188        assert_eq!(
1189            resolved[0],
1190            Resolved::Route {
1191                source: "lfo_1".into(),
1192                destination: "filter_1_cutoff".into(),
1193                amount: 0.25,
1194                polarity: Some(RoutePolarity::Unipolar),
1195                reason: Some("wobble".into()),
1196            }
1197        );
1198        assert_eq!(
1199            resolved[2],
1200            Resolved::Raw {
1201                key: "custom_warps_count".into(),
1202                value: Stored::Int(2),
1203                reason: Some("newer build".into()),
1204            }
1205        );
1206        let percent = resolve_one(
1207            r#"{"route":{"source":"env_2","dest":"osc_1_level","amount":"-50%"},"reason":"r"}"#,
1208        )
1209        .unwrap();
1210        assert!(
1211            matches!(percent, Resolved::Route { amount, polarity: None, .. } if (amount + 0.5).abs() < 1e-12)
1212        );
1213        let semis = resolve_one(
1214            r#"{"route":{"source":"env_2","dest":"osc_1_level","amount":"12 st"},"reason":"r"}"#,
1215        )
1216        .unwrap_err();
1217        assert!(semis.contains("percent of the destination"), "{semis}");
1218        let raw_known =
1219            resolve_one(r#"{"raw":{"key":"filter_1_cutoff","value":2},"reason":"r"}"#).unwrap_err();
1220        assert!(raw_known.contains("registry control"), "{raw_known}");
1221    }
1222
1223    #[test]
1224    fn every_record_kind_round_trips_through_json() {
1225        let text = list(
1226            r#"{"param":"filter_1_cutoff","value":"410 Hz","reason":"a"},{"param":"filter_1_on","value":true,"reason":"b"},{"param":"osc_1_unison_voices","value":5.0,"reason":"c"},{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"25%","polarity":"bipolar"},"reason":"d"},{"route":{"source":"env_2","dest":"osc_1_level","amount":"-10%"},"reason":"e"},{"menu":"filter_1_model","choice":"Ladder","reason":"f"},{"raw":{"key":"k","value":2},"reason":"g"},{"raw":{"key":"k2","value":0.5},"reason":"h"}"#,
1227        );
1228        let parsed = parse(&text, Path::Agent).unwrap();
1229        let again = parse(&serde_json::to_string(&parsed).unwrap(), Path::Agent).unwrap();
1230        assert_eq!(parsed, again);
1231    }
1232
1233    #[test]
1234    fn a_list_for_another_synth_is_rejected() {
1235        let parsed = parse(r#"{"synth":"serum2","edits":[]}"#, Path::Agent).unwrap();
1236        let error = resolve(&parsed, vital()).unwrap_err().to_string();
1237        assert!(
1238            error.contains("written for Serum 2, but the target is Vital"),
1239            "{error}"
1240        );
1241    }
1242
1243    #[test]
1244    fn legacy_controls_and_repeated_labels_are_refused() {
1245        let legacy = resolve_one(r#"{"param":"sub_on","value":true,"reason":"r"}"#).unwrap_err();
1246        assert!(legacy.contains("legacy control"), "{legacy}");
1247        let repeated =
1248            resolve_one(r#"{"menu":"osc_1_distortion_type","choice":"FM <- Osc","reason":"r"}"#)
1249                .unwrap_err();
1250        assert!(repeated.contains("several choices"), "{repeated}");
1251    }
1252
1253    #[test]
1254    fn a_stored_index_selects_a_repeated_label() {
1255        let spec = vital().get("osc_1_distortion_type").unwrap();
1256        let index = spec
1257            .choices
1258            .iter()
1259            .position(|label| label == "FM <- Osc")
1260            .unwrap();
1261        let stored = spec.stored_min + crate::cast::usize_to_f64(index);
1262        let resolved = resolve_one(&format!(
1263            r#"{{"param":"osc_1_distortion_type","value":{stored},"reason":"r"}}"#
1264        ))
1265        .unwrap();
1266        assert!(
1267            matches!(resolved, Resolved::Param { stored: got, .. } if got.to_bits() == stored.to_bits())
1268        );
1269        let outside = resolve_one(r#"{"param":"osc_1_distortion_type","value":99,"reason":"r"}"#)
1270            .unwrap_err();
1271        assert!(outside.contains("whole stored index"), "{outside}");
1272    }
1273
1274    #[test]
1275    fn a_count_between_steps_is_not_whole() {
1276        let error =
1277            resolve_one(r#"{"param":"osc_1_unison_voices","value":5.5,"reason":"r"}"#).unwrap_err();
1278        assert!(error.contains("5.5 is not a whole number"), "{error}");
1279        assert!(error.contains("the nearest is 6.0"), "{error}");
1280        let error =
1281            resolve_one(r#"{"param":"osc_1_unison_voices","value":20,"reason":"r"}"#).unwrap_err();
1282        assert!(error.contains("outside the stored range"), "{error}");
1283    }
1284
1285    #[test]
1286    fn uncalibrated_follows_the_registry() {
1287        let resolved =
1288            resolve_one(r#"{"param":"filter_1_resonance","value":"50%","reason":"r"}"#).unwrap();
1289        assert!(matches!(
1290            resolved,
1291            Resolved::Param {
1292                uncalibrated: true,
1293                ..
1294            }
1295        ));
1296        let resolved =
1297            resolve_one(r#"{"param":"filter_1_cutoff","value":"500 Hz","reason":"r"}"#).unwrap();
1298        assert!(matches!(
1299            resolved,
1300            Resolved::Param {
1301                uncalibrated: false,
1302                ..
1303            }
1304        ));
1305    }
1306
1307    #[test]
1308    fn decisions_parse_strictly() {
1309        let text = r#"{"synth":"vital","edits":[],"ambiguities":[{"id":"a","choice":"both","reason":"keep both"}]}"#;
1310        assert_eq!(parse(text, Path::Agent).unwrap().ambiguities.len(), 1);
1311        let bad = r#"{"synth":"vital","edits":[],"ambiguities":[{"id":"a","choice":"both"}]}"#;
1312        assert!(parse(bad, Path::Agent).is_err());
1313    }
1314}