Skip to main content

somatize_study/
space.rs

1//! What is being searched over: the named knobs and what each one may be.
2//!
3//! Ordered, and a `Vec` rather than a map on purpose: a grid enumerates in this
4//! order, a point writes itself down in this order, and both have to give the
5//! same answer on two machines that never spoke.
6
7use crate::{Point, Setting};
8use std::fmt;
9
10/// One knob and what it may be. Three kinds and not more: a bool is a `Choice`
11/// of two, and a *power of two between 16 and 512* is an `Int` read as a log.
12/// Not here: a **conditional** dimension, which needs a consumer first.
13#[derive(Debug, Clone, PartialEq)]
14pub enum Dimension {
15    /// Anything between the two.
16    Real {
17        /// The bottom, included.
18        low: f64,
19        /// The top, included.
20        high: f64,
21        /// Drawn evenly in the **logarithm**, which is the only sane way to
22        /// search a learning rate: `1e-5..1e-1` spends four fifths of a linear
23        /// draw above `0.02`.
24        log: bool,
25    },
26    /// A whole number between the two, both included.
27    Int {
28        /// The bottom, included.
29        low: i64,
30        /// The top, included.
31        high: i64,
32    },
33    /// One of these, by name. Nothing is read into their order.
34    Choice(Vec<String>),
35}
36
37impl Dimension {
38    /// Whether this says anything at all, and is the right way round.
39    fn sound(&self) -> bool {
40        match self {
41            Self::Real { low, high, log } => {
42                low < high && low.is_finite() && high.is_finite() && (!log || *low > 0.0)
43            }
44            Self::Int { low, high } => low < high,
45            Self::Choice(options) => !options.is_empty(),
46        }
47    }
48
49    /// How many values a grid takes from it, given how finely it is asked to cut
50    /// what is continuous. A `Choice` takes all of them; an `Int` takes all of
51    /// them too unless there are more than `steps`.
52    pub fn grid_of(&self, steps: usize) -> usize {
53        match self {
54            Self::Real { .. } => steps.max(1),
55            Self::Int { low, high } => ((high - low + 1) as usize).min(steps.max(1)),
56            Self::Choice(options) => options.len(),
57        }
58    }
59}
60
61impl fmt::Display for Dimension {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Real {
65                low,
66                high,
67                log: false,
68            } => write!(f, "real({low},{high})"),
69            Self::Real {
70                low,
71                high,
72                log: true,
73            } => write!(f, "logreal({low},{high})"),
74            Self::Int { low, high } => write!(f, "int({low},{high})"),
75            Self::Choice(options) => write!(f, "choice({})", options.join("|")),
76        }
77    }
78}
79
80/// The knobs, in the order they were declared.
81///
82/// ```
83/// use somatize_study::{Dimension, Space};
84///
85/// let space = Space::new()
86///     .with("lr", Dimension::Real { low: 1e-5, high: 1e-1, log: true })?
87///     .with("batch", Dimension::Int { low: 16, high: 128 })?
88///     .with("optimizer", Dimension::Choice(vec!["adam".into(), "sgd".into()]))?;
89/// assert_eq!(space.len(), 3);
90/// # Ok::<(), somatize_study::SpaceError>(())
91/// ```
92#[derive(Debug, Clone, Default, PartialEq)]
93pub struct Space {
94    dimensions: Vec<(String, Dimension)>,
95}
96
97impl Space {
98    /// Nothing to search yet.
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// One more knob. The name has to be new, and what it may be has to be
104    /// something: an empty `Choice` or a range the wrong way round is refused
105    /// **here**, where it was written, and not as a search that quietly only
106    /// ever tried one value.
107    pub fn with(
108        mut self,
109        name: impl Into<String>,
110        dimension: Dimension,
111    ) -> Result<Self, SpaceError> {
112        let name = name.into();
113        if self.dimensions.iter().any(|(taken, _)| taken == &name) {
114            return Err(SpaceError::Taken(name));
115        }
116        if !dimension.sound() {
117            return Err(SpaceError::Empty(name, dimension));
118        }
119        // A point writes itself down as `name=value,name=value`, and a study
120        // reads it back off a shared folder. A name or an option carrying one of
121        // those two characters makes that text ambiguous, and the day it is read
122        // wrong there is nothing left to tell which knob was meant.
123        if let Some(text) = punctuated(&name, &dimension) {
124            return Err(SpaceError::Unreadable(name, text));
125        }
126        self.dimensions.push((name, dimension));
127        Ok(self)
128    }
129
130    /// The point that text names, read against these knobs.
131    ///
132    /// The other half of [`Point`]'s `Display`, and it needs the space: `batch=64`
133    /// on its own does not say whether 64 is a whole number or a
134    /// [`Choice`](Dimension::Choice) spelt `"64"`. This is what makes a study's
135    /// history come back in **one scan and no fetches**.
136    ///
137    /// Every knob has to be there and nothing else may be: a record written
138    /// against another space is a different study.
139    /// ```
140    /// use somatize_study::{Dimension, Space};
141    ///
142    /// let space = Space::new()
143    ///     .with("lr", Dimension::Real { low: 1e-5, high: 1e-1, log: true })?
144    ///     .with("opt", Dimension::Choice(vec!["adam".into(), "sgd".into()]))?;
145    /// let point = space.read("lr=0.001,opt=adam")?;
146    ///
147    /// assert_eq!(point.to_string(), "lr=0.001,opt=adam");
148    /// # Ok::<(), Box<dyn std::error::Error>>(())
149    /// ```
150    pub fn read(&self, said: &str) -> Result<Point, ReadError> {
151        let mut given: Vec<(&str, &str)> = Vec::new();
152        for piece in said.split(',').filter(|piece| !piece.trim().is_empty()) {
153            let (name, value) = piece
154                .split_once('=')
155                .ok_or_else(|| ReadError::Shapeless(piece.trim().to_string()))?;
156            given.push((name.trim(), value.trim()));
157        }
158
159        let mut settings = Vec::with_capacity(self.dimensions.len());
160        for (name, dimension) in &self.dimensions {
161            let value = given
162                .iter()
163                .find(|(said, _)| said == name)
164                .ok_or_else(|| ReadError::Missing(name.clone()))?
165                .1;
166            let setting = understand(dimension, value).ok_or_else(|| {
167                ReadError::NotIn(name.clone(), dimension.clone(), value.to_string())
168            })?;
169            settings.push((name.clone(), setting));
170        }
171
172        if let Some((stranger, _)) = given
173            .iter()
174            .find(|(name, _)| !self.dimensions.iter().any(|(taken, _)| taken == name))
175        {
176            return Err(ReadError::Stranger(stranger.to_string()));
177        }
178        Ok(Point::of(settings))
179    }
180
181    /// The knobs, in declaration order.
182    pub fn dimensions(&self) -> &[(String, Dimension)] {
183        &self.dimensions
184    }
185
186    /// How many knobs there are.
187    pub fn len(&self) -> usize {
188        self.dimensions.len()
189    }
190
191    /// Whether there is nothing to search.
192    pub fn is_empty(&self) -> bool {
193        self.dimensions.is_empty()
194    }
195}
196
197impl fmt::Display for Space {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        let said: Vec<String> = self
200            .dimensions
201            .iter()
202            .map(|(name, dimension)| format!("{name}={dimension}"))
203            .collect();
204        f.write_str(&said.join(","))
205    }
206}
207
208/// The name or the option that carries a `,` or an `=`, if either does.
209fn punctuated(name: &str, dimension: &Dimension) -> Option<String> {
210    let loud = |text: &str| text.contains(',') || text.contains('=');
211    if loud(name) {
212        return Some(name.to_string());
213    }
214    match dimension {
215        Dimension::Choice(options) => options.iter().find(|option| loud(option)).cloned(),
216        _ => None,
217    }
218}
219
220/// That text as a setting of that knob, or `None` when it is not one of its
221/// values — the wrong kind, an option nobody declared, or a number outside the
222/// range. Everything a sampler produces sits inside, so this only refuses what
223/// was written against a different space.
224fn understand(dimension: &Dimension, value: &str) -> Option<Setting> {
225    match dimension {
226        Dimension::Real { low, high, .. } => value
227            .parse::<f64>()
228            .ok()
229            .filter(|read| (low..=high).contains(&read))
230            .map(Setting::Real),
231        Dimension::Int { low, high } => value
232            .parse::<i64>()
233            .ok()
234            .filter(|read| (low..=high).contains(&read))
235            .map(Setting::Int),
236        Dimension::Choice(options) => options
237            .iter()
238            .find(|option| *option == value)
239            .cloned()
240            .map(Setting::Choice),
241    }
242}
243
244/// Why that is not a knob that can be searched.
245#[derive(Debug, Clone, PartialEq)]
246pub enum SpaceError {
247    /// Two knobs by the same name.
248    Taken(String),
249    /// A knob with nothing in it, or a range the wrong way round.
250    Empty(String, Dimension),
251    /// A name, or one of a choice's options, carrying the punctuation a written
252    /// point is made of.
253    Unreadable(String, String),
254}
255
256impl fmt::Display for SpaceError {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        match self {
259            Self::Taken(name) => write!(
260                f,
261                "`{name}` is already a dimension of this space: a point would have two \
262                 values for it and no way to say which"
263            ),
264            Self::Empty(name, dimension) => write!(
265                f,
266                "`{name}` as `{dimension}` has nothing to draw from: a range needs its \
267                 bottom below its top, a choice needs an option, and a logarithmic range \
268                 needs to start above zero"
269            ),
270            Self::Unreadable(name, text) => write!(
271                f,
272                "`{text}`, of the knob `{name}`, has a `,` or an `=` in it, and a point \
273                 writes itself down as `name=value,name=value`: kept, it would be a trial \
274                 name that cannot be read back, and by then which knob was meant is gone"
275            ),
276        }
277    }
278}
279
280impl std::error::Error for SpaceError {}
281
282/// Why that text is not a point of this space.
283#[derive(Debug, Clone, PartialEq)]
284pub enum ReadError {
285    /// A piece with no `=` in it.
286    Shapeless(String),
287    /// A knob of this space the text says nothing about.
288    Missing(String),
289    /// A name that is not a knob of this space.
290    Stranger(String),
291    /// A value that is not one this knob could take.
292    NotIn(String, Dimension, String),
293}
294
295impl fmt::Display for ReadError {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        match self {
298            Self::Shapeless(piece) => write!(
299                f,
300                "`{piece}` is not `name=value`, and a point is written down as those \
301                 separated by commas"
302            ),
303            Self::Missing(name) => write!(
304                f,
305                "this space has a knob `{name}` and the text sets nothing for it: a point \
306                 of a space sets every one of its knobs, so this was written against \
307                 another space"
308            ),
309            Self::Stranger(name) => write!(
310                f,
311                "`{name}` is not a knob of this space: the text was written against \
312                 another one, and reading the part that does match would be a point of \
313                 neither"
314            ),
315            Self::NotIn(name, dimension, value) => write!(
316                f,
317                "`{value}` is not a value of `{name}`, which is `{dimension}`: it is \
318                 either the wrong kind, an option nobody declared, or outside the range"
319            ),
320        }
321    }
322}
323
324impl std::error::Error for ReadError {}