auracle_grammar/edit.rs
1//! Address-based knob edits: turning a panel knob is a write at a trace
2//! address, so hand edits and MH proposals move through the same encoding
3//! and cannot drift from the grammar.
4
5use fugue_evo::genome::trace_genome::{ChoiceValue, TraceGenome};
6use thiserror::Error;
7
8use crate::term::PatchTree;
9
10/// A knob-edit value.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub enum ParamValue {
13 /// Continuous parameter, clamped to `[0, 1]`.
14 Continuous(f64),
15 /// Enum / octave selector index (clamped to the site's category count).
16 Index(usize),
17}
18
19/// Why an edit was rejected.
20#[derive(Debug, Error)]
21pub enum EditError {
22 /// The address does not exist in this patch.
23 #[error("no such address in patch: {0}")]
24 UnknownAddress(String),
25 /// The address is a structural choice (`#leaf`/`#src`/`#op`/`#mod`);
26 /// structure changes go through evolution, not knob edits.
27 #[error("address {0} is structural; knobs cannot rewire the patch")]
28 Structural(String),
29 /// Value kind does not match the site (continuous vs. enum).
30 #[error("value kind mismatch at {0}")]
31 KindMismatch(String),
32 /// The edited trace failed to decode (should not happen for
33 /// parameter-only edits).
34 #[error("edited patch failed to decode: {0}")]
35 Decode(String),
36}
37
38/// Category count for enum sites, by site name.
39fn enum_arity(site: &str) -> Option<usize> {
40 match site {
41 "wave" => Some(4),
42 "color" => Some(2),
43 "fkind" => Some(4),
44 "oct" => Some(5),
45 "table" => Some(8),
46 "dmode" => Some(3),
47 _ => None,
48 }
49}
50
51fn is_structural(site: &str) -> bool {
52 // `modop` and `pairop` joined in wave 2C: which CV processor sits in a mod
53 // chain is a production, not a knob, exactly as `#op` is for the audio
54 // tree. (`qscale` and `rmode` are *not* here: they select inside a module
55 // that is already placed, so they are ordinary continuous sites — see
56 // `crate::term::quant_scale_index`.)
57 matches!(site, "leaf" | "src" | "op" | "mod" | "modop" | "pairop")
58}
59
60/// Split a full address string (`key#site`) into its key and site.
61pub fn split_addr(addr: &str) -> (&str, &str) {
62 match addr.rsplit_once('#') {
63 Some((k, s)) => (k, s),
64 None => (addr, ""),
65 }
66}
67
68/// Return a copy of `tree` with the choice at `addr` set to `value`.
69///
70/// Continuous values are clamped to `[0, 1]`; enum indices are clamped to the
71/// site's arity. Structural sites are rejected — restructuring is evolution's
72/// job (or a future explicit structure-edit surface), not a knob gesture.
73///
74/// Identity is carried across, because a knob turn changes no structure and
75/// therefore renames nothing. That has to be said explicitly: this edit is
76/// performed on the *trace* — a map from address to value — and
77/// [`PatchTree::from_trace`] rebuilds the term through the genome decoder,
78/// which has never heard of a [`crate::term::Uid`] and cannot. So every node
79/// comes back anonymous unless it is told otherwise, which is the same round
80/// trip (and the same fix) as the refinement path in `record_child`. Left
81/// alone it is not a cosmetic loss: every lock id collapses onto the same
82/// `0#site`, a re-render looks to the motion system like the entire patch
83/// arriving at once, and every hand-placed position is orphaned — by turning
84/// one knob.
85pub fn set_param(tree: &PatchTree, addr: &str, value: ParamValue) -> Result<PatchTree, EditError> {
86 let (_, site) = split_addr(addr);
87 if is_structural(site) {
88 return Err(EditError::Structural(addr.into()));
89 }
90 let mut trace = tree.to_trace();
91 let a = trace
92 .choices
93 .keys()
94 .find(|k| &***k == addr)
95 .cloned()
96 .ok_or_else(|| EditError::UnknownAddress(addr.into()))?;
97 let slot = trace.choices.get_mut(&a).expect("present");
98 match (&slot.value, value) {
99 (ChoiceValue::F64(_), ParamValue::Continuous(v)) => {
100 slot.value = ChoiceValue::F64(v.clamp(0.0, 1.0));
101 }
102 (ChoiceValue::Usize(_), ParamValue::Index(i)) => {
103 let n = enum_arity(site).unwrap_or(usize::MAX);
104 slot.value = ChoiceValue::Usize(i.min(n.saturating_sub(1)));
105 }
106 _ => return Err(EditError::KindMismatch(addr.into())),
107 }
108 let mut edited = PatchTree::from_trace(&trace).map_err(|e| EditError::Decode(e.to_string()))?;
109 // Positional and shallow-by-variant, and a param edit changes neither, so
110 // the match is total: every node gets its own identity back. Deliberately
111 // *not* followed by `ensure_uids` — a tree that was never settled must
112 // stay unsettled rather than mint a fresh set of ids on every knob turn.
113 edited.inherit_uids(tree);
114 Ok(edited)
115}