Skip to main content

expectation_model/
vocabulary.rs

1//! The suite's ubiquitous language: the states the system can be in and the events that
2//! happen to it. A step only means something if it is written in these terms, which is what
3//! lets two expectations be compared mechanically.
4
5use crate::condition::{Condition, Constraint, Interval};
6use crate::diagnostic::Span;
7use crate::text;
8use std::fmt;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct VarId(pub u32);
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct EventId(pub u32);
15
16/// Most values an enumerated state can have (they're stored as a bit set).
17pub const MAX_VALUES: usize = 64;
18
19#[derive(Clone, Debug, PartialEq)]
20pub enum VarKind {
21    /// One of a fixed set of named values.
22    Enum(Vec<String>),
23    /// A number in `min..=max`, with an optional unit such as `%`.
24    Number { min: f64, max: f64, unit: Option<String> },
25}
26
27/// A state of the system: "the kettle is off | heating | dry".
28#[derive(Clone, Debug)]
29pub struct Variable {
30    pub id: VarId,
31    /// As declared, e.g. "the kettle".
32    pub subject: String,
33    /// Normalised for matching, e.g. "kettle".
34    pub key: String,
35    pub kind: VarKind,
36    /// The verb it was declared with, which steps about it read with: "is", "are", "has"
37    /// ("the drawing has 0..100 shapes") or "have".
38    pub verb: String,
39    pub span: Span,
40    /// How docs tell the reader to change it, with `{value}` for the value ("open the dial
41    /// to {value}"). Without it: "set the dial to 80 %".
42    pub set: Option<String>,
43    /// What the reader sees, from `Shows:` lines: for one value ("dry", "the display says
44    /// DRY", or "at least 99 %" for a number), or for any value with `{value}` in it
45    /// (`None`, "the button reads UPLOAD {value}").
46    pub shows: Vec<(Option<String>, String)>,
47}
48
49/// Something that happens to the system: "the user presses reset".
50#[derive(Clone, Debug)]
51pub struct Event {
52    pub id: EventId,
53    pub text: String,
54    pub key: String,
55    pub span: Span,
56    /// How docs tell the reader to make it happen ("press Space"). Without it, an event
57    /// with an actor ("the user clicks the canvas") becomes an instruction ("click the canvas"),
58    /// and one without ("the timer runs out") is something that happens.
59    pub say: Option<String>,
60}
61
62/// A concrete value, as the system under test reports it or is driven to.
63#[derive(Clone, Debug, PartialEq)]
64pub enum Value {
65    Enum(String),
66    Number(f64),
67}
68
69impl fmt::Display for Value {
70    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
71        match self {
72            Value::Enum(s) => f.write_str(s),
73            Value::Number(v) => f.write_str(&text::number(*v)),
74        }
75    }
76}
77
78impl From<&str> for Value {
79    fn from(s: &str) -> Self {
80        Value::Enum(s.to_string())
81    }
82}
83
84impl From<String> for Value {
85    fn from(s: String) -> Self {
86        Value::Enum(s)
87    }
88}
89
90impl From<f64> for Value {
91    fn from(v: f64) -> Self {
92        Value::Number(v)
93    }
94}
95
96impl From<f32> for Value {
97    fn from(v: f32) -> Self {
98        Value::Number(v as f64)
99    }
100}
101
102impl Value {
103    pub fn as_str(&self) -> Option<&str> {
104        match self {
105            Value::Enum(s) => Some(s),
106            Value::Number(_) => None,
107        }
108    }
109
110    pub fn as_f64(&self) -> Option<f64> {
111        match self {
112            Value::Number(v) => Some(*v),
113            Value::Enum(_) => None,
114        }
115    }
116}
117
118impl Variable {
119    /// What the reader sees when it's exactly one value, if a `Shows:` line says.
120    /// What the reader sees when it changes by `shift`, if a `Shows:` line says: one keyed
121    /// "goes up by 1", "goes down by 10 m" or "stays the same", as relative Thens write them.
122    pub fn shown_shift(&self, shift: crate::spec::Shift) -> Option<String> {
123        let keys: Vec<String> = match shift {
124            crate::spec::Shift::Same => vec!["stays the same".into()],
125            crate::spec::Shift::By(d) => {
126                let way = if d > 0.0 { "up" } else { "down" };
127                let n = text::number(d.abs());
128                let unit = match &self.kind {
129                    VarKind::Number { unit: Some(u), .. } => format!(" {u}"),
130                    _ => String::new(),
131                };
132                vec![format!("goes {way} by {n}"), format!("goes {way} by {n}{unit}")]
133            }
134        };
135        self.shows
136            .iter()
137            .find(|(v, _)| v.as_ref().is_some_and(|v| keys.iter().any(|k| k.eq_ignore_ascii_case(v))))
138            .map(|(_, t)| t.clone())
139    }
140
141    pub fn shown(&self, c: &Constraint) -> Option<String> {
142        // A line for exactly this constraint first: "dry", or "at least 99 %" for a number.
143        let said = self.predicate(c);
144        let said = ["is ", "are ", "has ", "have "].iter().find_map(|v| said.strip_prefix(v)).unwrap_or(&said);
145        if let Some((_, t)) = self.shows.iter().find(|(v, _)| v.as_ref().is_some_and(|v| v.eq_ignore_ascii_case(said)))
146        {
147            return Some(t.clone());
148        }
149        let value = match (&self.kind, c) {
150            (VarKind::Enum(values), Constraint::OneOf(mask)) if mask.count_ones() == 1 => {
151                values[mask.trailing_zeros() as usize].clone()
152            }
153            (VarKind::Number { .. }, Constraint::Range(r)) if r.lo == r.hi && !r.lo_open && !r.hi_open => {
154                text::number(r.lo)
155            }
156            _ => return None,
157        };
158        let own = self.shows.iter().find(|(v, _)| v.as_ref().is_some_and(|v| v.eq_ignore_ascii_case(&value)));
159        let any = || self.shows.iter().find(|(v, _)| v.is_none());
160        own.or_else(any).map(|(_, t)| t.replace("{value}", &value))
161    }
162
163    /// The constraint that allows every value.
164    pub fn full(&self) -> Constraint {
165        match &self.kind {
166            VarKind::Enum(values) => Constraint::OneOf(mask_all(values.len())),
167            VarKind::Number { min, max, .. } => Constraint::Range(Interval::closed(*min, *max)),
168        }
169    }
170
171    pub fn value_index(&self, name: &str) -> Option<usize> {
172        let VarKind::Enum(values) = &self.kind else { return None };
173        let k = text::normalize(name);
174        values.iter().position(|v| text::normalize(v) == k)
175    }
176
177    /// A concrete value inside `c` to drive the system with: the first allowed name, or the
178    /// middle of a numeric range.
179    pub fn witness(&self, c: &Constraint) -> Option<Value> {
180        match (&self.kind, c) {
181            (VarKind::Enum(values), Constraint::OneOf(mask)) => {
182                (0..values.len()).find(|i| mask & (1 << i) != 0).map(|i| Value::Enum(values[i].clone()))
183            }
184            (VarKind::Number { .. }, Constraint::Range(r)) if !r.is_empty() => Some(Value::Number(r.midpoint())),
185            _ => None,
186        }
187    }
188
189    /// Does the observed value satisfy `c`? Numbers get a tolerance of a millionth of the
190    /// state's range, so a value that went through an `f32` still reads as what it was set to.
191    pub fn admits(&self, c: &Constraint, v: &Value) -> bool {
192        match (&self.kind, c, v) {
193            (_, Constraint::OneOf(mask), Value::Enum(name)) => {
194                self.value_index(name).is_some_and(|i| mask & (1 << i) != 0)
195            }
196            (VarKind::Number { min, max, .. }, Constraint::Range(r), Value::Number(x)) => {
197                let eps = (max - min) * 1e-6;
198                !r.is_empty() && (r.contains(*x) || (r.lo - eps..=r.hi + eps).contains(x))
199            }
200            _ => false,
201        }
202    }
203
204    /// "the kettle is heating", "the dial is above 55 %".
205    pub fn describe(&self, c: &Constraint) -> String {
206        format!("{} {}", self.subject, self.predicate(c))
207    }
208
209    /// "is heating", "is not dry", "is between 20 and 40 %" ("are …" for a plural subject).
210    pub fn predicate(&self, c: &Constraint) -> String {
211        let p = self.singular_predicate(c);
212        let Some(rest) = p.strip_prefix("is ") else { return p };
213        match self.verb.as_str() {
214            "has" | "have" => {
215                let rest = rest.replacen("above ", "more than ", 1).replacen("below ", "fewer than ", 1);
216                match rest.strip_prefix("not ") {
217                    Some(r) => format!("{} {r}", if self.verb == "has" { "doesn't have" } else { "don't have" }),
218                    None => format!("{} {rest}", self.verb),
219                }
220            }
221            "are" => format!("are {rest}"),
222            _ => p,
223        }
224    }
225
226    fn singular_predicate(&self, c: &Constraint) -> String {
227        match (&self.kind, c) {
228            (VarKind::Enum(values), Constraint::OneOf(mask)) => {
229                let named = |m: u64| -> Vec<String> {
230                    (0..values.len()).filter(|i| m & (1 << i) != 0).map(|i| values[i].clone()).collect()
231                };
232                let yes = named(*mask);
233                let no = named(mask_all(values.len()) & !mask);
234                if yes.is_empty() {
235                    "is impossible".into()
236                } else if yes.len() > 1 && no.len() == 1 {
237                    format!("is not {}", no[0])
238                } else {
239                    format!("is {}", yes.join(" or "))
240                }
241            }
242            (VarKind::Number { min, max, unit }, Constraint::Range(r)) => {
243                let u = |v: f64| match unit {
244                    // "1 item", not "1 items".
245                    Some(u) if v == 1.0 && u.len() > 1 && u.ends_with('s') => format!("1 {}", &u[..u.len() - 1]),
246                    Some(u) => format!("{} {u}", text::number(v)),
247                    None => text::number(v),
248                };
249                let open_lo = r.lo <= *min && !r.lo_open;
250                let open_hi = r.hi >= *max && !r.hi_open;
251                if r.is_empty() {
252                    "is impossible".into()
253                } else if r.lo == r.hi {
254                    format!("is {}", u(r.lo))
255                } else if open_lo && open_hi {
256                    "is anything".into()
257                } else if open_lo {
258                    format!("is {} {}", if r.hi_open { "below" } else { "at most" }, u(r.hi))
259                } else if open_hi {
260                    format!("is {} {}", if r.lo_open { "above" } else { "at least" }, u(r.lo))
261                } else if !r.lo_open && !r.hi_open {
262                    format!("is between {} and {}", text::number(r.lo), u(r.hi))
263                } else {
264                    let lo = if r.lo_open { "above" } else { "at least" };
265                    let hi = if r.hi_open { "below" } else { "at most" };
266                    format!("is {lo} {} and {hi} {}", u(r.lo), u(r.hi))
267                }
268            }
269            _ => "is (mismatched constraint)".into(),
270        }
271    }
272}
273
274pub(crate) fn mask_all(n: usize) -> u64 {
275    if n >= 64 { u64::MAX } else { (1u64 << n) - 1 }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn numbers_are_compared_with_a_little_tolerance() {
284        let thr = Variable {
285            id: VarId(0),
286            subject: "the dial".into(),
287            key: "dial".into(),
288            kind: VarKind::Number { min: 0.0, max: 100.0, unit: Some("%".into()) },
289            verb: "is".into(),
290            span: Span::new("t", 1),
291            set: None,
292            shows: vec![],
293        };
294        let eighty = Constraint::Range(Interval::point(80.0));
295        assert!(thr.admits(&eighty, &Value::Number(0.8f32 as f64 * 100.0)));
296        assert!(!thr.admits(&eighty, &Value::Number(80.01)));
297    }
298}
299
300#[derive(Clone, Debug, Default)]
301pub struct Vocabulary {
302    pub variables: Vec<Variable>,
303    pub events: Vec<Event>,
304    pub phrases: Vec<crate::spec::Phrase>,
305}
306
307impl Vocabulary {
308    pub fn var(&self, id: VarId) -> &Variable {
309        &self.variables[id.0 as usize]
310    }
311
312    pub fn event(&self, id: EventId) -> &Event {
313        &self.events[id.0 as usize]
314    }
315
316    pub fn find_var(&self, subject: &str) -> Option<&Variable> {
317        let k = text::key(subject);
318        self.variables.iter().find(|v| v.key == k)
319    }
320
321    pub fn phrase(&self, id: crate::spec::PhraseId) -> &crate::spec::Phrase {
322        &self.phrases[id.0 as usize]
323    }
324
325    pub fn find_event(&self, phrase: &str) -> Option<&Event> {
326        let k = text::key(phrase);
327        self.events.iter().find(|e| e.key == k)
328    }
329
330    /// Each constraint of a condition as a phrase, in declaration order.
331    pub fn facts(&self, c: &Condition) -> Vec<String> {
332        c.iter().map(|(v, k)| self.var(v).describe(k)).collect()
333    }
334
335    /// A whole condition as one phrase: "the kettle is off and the dial is 0 %".
336    pub fn describe(&self, c: &Condition) -> String {
337        let facts = self.facts(c);
338        if facts.is_empty() { "anything".into() } else { text::join_and(&facts) }
339    }
340}