gemini_adk_rs/
expr.rs

1//! `Expr` — a closed, serializable expression vocabulary over session state.
2//!
3//! Where [`Guard`](crate::flow::Guard) is the closed vocabulary for *boolean*
4//! questions about state and marking, `Expr` is the closed vocabulary for
5//! *values* computed from state: the language of computed (derived) state
6//! variables authored as data. Every atom is a named, parameterized operation;
7//! there are no closures, so an `Expr` round-trips through JSON, can be edited
8//! in a UI, validated at load time ([`Expr::keys_read`] feeds the state-key
9//! diff), and evaluated identically in the live runtime, the offline
10//! simulator, and generated code.
11//!
12//! ```
13//! use gemini_adk_rs::expr::Expr;
14//! use gemini_adk_rs::state::State;
15//!
16//! // risk = 0.6 * overdue_ratio + 0.4 * missed_payments / 10
17//! let expr: Expr = serde_json::from_value(serde_json::json!({
18//!     "add": [
19//!         {"mul": [{"const": 0.6}, {"key": "overdue_ratio"}]},
20//!         {"mul": [{"const": 0.04}, {"key": "missed_payments"}]}
21//!     ]
22//! })).unwrap();
23//!
24//! let state = State::new();
25//! state.set("overdue_ratio", 0.5).unwrap();
26//! state.set("missed_payments", 3).unwrap();
27//! assert_eq!(expr.eval(&state), Some(serde_json::json!(0.42)));
28//! ```
29//!
30//! ## Evaluation semantics
31//!
32//! - Reads go through [`State::get`], so the `derived:` fallback applies —
33//!   one computed variable can read another by its bare key.
34//! - Arithmetic and comparison atoms are *strict*: a missing key or
35//!   non-numeric operand makes the whole expression `None` (no write).
36//! - Logic atoms (`all`/`any`/`not`) are *total*: a missing or non-boolean
37//!   operand counts as `false`, mirroring `Guard::is_true` on an unset key.
38//! - `coalesce` returns the first operand that evaluates to a value; `if`
39//!   selects a branch on the truthiness of its condition.
40
41use std::collections::BTreeSet;
42
43use serde::{Deserialize, Serialize};
44use serde_json::{Value, json};
45
46use crate::state::State;
47
48/// A serializable expression over session state. See the module docs.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
50#[serde(rename_all = "snake_case")]
51pub enum Expr {
52    /// A literal value.
53    Const(Value),
54    /// Read a state key (with the `derived:` fallback of [`State::get`]).
55    Key(String),
56    /// Numeric sum of all operands.
57    Add(Vec<Expr>),
58    /// Numeric product of all operands.
59    Mul(Vec<Expr>),
60    /// `a - b`.
61    Sub(Box<Expr>, Box<Expr>),
62    /// `a / b` (`None` when `b` is zero).
63    Div(Box<Expr>, Box<Expr>),
64    /// Numeric minimum of all operands.
65    Min(Vec<Expr>),
66    /// Numeric maximum of all operands.
67    Max(Vec<Expr>),
68    /// Structural equality of two values.
69    Eq(Box<Expr>, Box<Expr>),
70    /// `a > b` (numeric).
71    Gt(Box<Expr>, Box<Expr>),
72    /// `a >= b` (numeric).
73    Gte(Box<Expr>, Box<Expr>),
74    /// `a < b` (numeric).
75    Lt(Box<Expr>, Box<Expr>),
76    /// `a <= b` (numeric).
77    Lte(Box<Expr>, Box<Expr>),
78    /// `true` when every operand is `true` (missing counts as `false`).
79    All(Vec<Expr>),
80    /// `true` when any operand is `true` (missing counts as `false`).
81    Any(Vec<Expr>),
82    /// Boolean negation (missing counts as `false`, so `not` of it is `true`).
83    Not(Box<Expr>),
84    /// Branch on a condition's truthiness.
85    If {
86        /// Condition (truthy = boolean `true`).
87        when: Box<Expr>,
88        /// Value when the condition holds.
89        then: Box<Expr>,
90        /// Value otherwise.
91        #[serde(rename = "else")]
92        otherwise: Box<Expr>,
93    },
94    /// The first operand that evaluates to a value.
95    Coalesce(Vec<Expr>),
96    /// String concatenation of all operands (numbers/booleans stringified;
97    /// missing operands contribute nothing).
98    Concat(Vec<Expr>),
99    /// How many of the named state keys are `true`.
100    CountTrue(Vec<String>),
101}
102
103impl Expr {
104    /// Evaluate against state. `None` means "no value" — a computed variable
105    /// skips its write for this cycle.
106    pub fn eval(&self, state: &State) -> Option<Value> {
107        match self {
108            Expr::Const(v) => Some(v.clone()),
109            Expr::Key(k) => state.get::<Value>(k),
110            Expr::Add(items) => nary(items, state, |acc, n| acc + n, 0.0),
111            Expr::Mul(items) => nary(items, state, |acc, n| acc * n, 1.0),
112            Expr::Sub(a, b) => Some(number(num(a, state)? - num(b, state)?)),
113            Expr::Div(a, b) => {
114                let d = num(b, state)?;
115                if d == 0.0 {
116                    None
117                } else {
118                    Some(number(num(a, state)? / d))
119                }
120            }
121            Expr::Min(items) => fold_nums(items, state, f64::min),
122            Expr::Max(items) => fold_nums(items, state, f64::max),
123            Expr::Eq(a, b) => Some(Value::Bool(a.eval(state)? == b.eval(state)?)),
124            Expr::Gt(a, b) => Some(Value::Bool(num(a, state)? > num(b, state)?)),
125            Expr::Gte(a, b) => Some(Value::Bool(num(a, state)? >= num(b, state)?)),
126            Expr::Lt(a, b) => Some(Value::Bool(num(a, state)? < num(b, state)?)),
127            Expr::Lte(a, b) => Some(Value::Bool(num(a, state)? <= num(b, state)?)),
128            Expr::All(items) => Some(Value::Bool(items.iter().all(|e| truthy(e, state)))),
129            Expr::Any(items) => Some(Value::Bool(items.iter().any(|e| truthy(e, state)))),
130            Expr::Not(e) => Some(Value::Bool(!truthy(e, state))),
131            Expr::If {
132                when,
133                then,
134                otherwise,
135            } => {
136                if truthy(when, state) {
137                    then.eval(state)
138                } else {
139                    otherwise.eval(state)
140                }
141            }
142            Expr::Coalesce(items) => items.iter().find_map(|e| e.eval(state)),
143            Expr::Concat(items) => {
144                let mut out = String::new();
145                for item in items {
146                    match item.eval(state) {
147                        Some(Value::String(s)) => out.push_str(&s),
148                        Some(Value::Null) | None => {}
149                        Some(v) => out.push_str(&v.to_string()),
150                    }
151                }
152                Some(Value::String(out))
153            }
154            Expr::CountTrue(keys) => Some(json!(
155                keys.iter()
156                    .filter(|k| state.get::<bool>(k).unwrap_or(false))
157                    .count()
158            )),
159        }
160    }
161
162    /// Every state key this expression reads, recursively — the dependency
163    /// set of a computed variable, and the load-time read universe for
164    /// validation.
165    pub fn keys_read(&self) -> BTreeSet<String> {
166        let mut keys = BTreeSet::new();
167        self.collect_keys(&mut keys);
168        keys
169    }
170
171    fn collect_keys(&self, keys: &mut BTreeSet<String>) {
172        match self {
173            Expr::Const(_) => {}
174            Expr::Key(k) => {
175                keys.insert(k.clone());
176            }
177            Expr::Add(items)
178            | Expr::Mul(items)
179            | Expr::Min(items)
180            | Expr::Max(items)
181            | Expr::All(items)
182            | Expr::Any(items)
183            | Expr::Coalesce(items)
184            | Expr::Concat(items) => {
185                for item in items {
186                    item.collect_keys(keys);
187                }
188            }
189            Expr::Sub(a, b)
190            | Expr::Div(a, b)
191            | Expr::Eq(a, b)
192            | Expr::Gt(a, b)
193            | Expr::Gte(a, b)
194            | Expr::Lt(a, b)
195            | Expr::Lte(a, b) => {
196                a.collect_keys(keys);
197                b.collect_keys(keys);
198            }
199            Expr::Not(e) => e.collect_keys(keys),
200            Expr::If {
201                when,
202                then,
203                otherwise,
204            } => {
205                when.collect_keys(keys);
206                then.collect_keys(keys);
207                otherwise.collect_keys(keys);
208            }
209            Expr::CountTrue(ks) => keys.extend(ks.iter().cloned()),
210        }
211    }
212
213    /// A compact human-readable rendering, for diagnostics and UI labels.
214    pub fn describe(&self) -> String {
215        match self {
216            Expr::Const(v) => v.to_string(),
217            Expr::Key(k) => k.clone(),
218            Expr::Add(items) => infix(items, " + "),
219            Expr::Mul(items) => infix(items, " * "),
220            Expr::Sub(a, b) => format!("({} - {})", a.describe(), b.describe()),
221            Expr::Div(a, b) => format!("({} / {})", a.describe(), b.describe()),
222            Expr::Min(items) => format!("min({})", infix_bare(items)),
223            Expr::Max(items) => format!("max({})", infix_bare(items)),
224            Expr::Eq(a, b) => format!("({} == {})", a.describe(), b.describe()),
225            Expr::Gt(a, b) => format!("({} > {})", a.describe(), b.describe()),
226            Expr::Gte(a, b) => format!("({} >= {})", a.describe(), b.describe()),
227            Expr::Lt(a, b) => format!("({} < {})", a.describe(), b.describe()),
228            Expr::Lte(a, b) => format!("({} <= {})", a.describe(), b.describe()),
229            Expr::All(items) => format!("all({})", infix_bare(items)),
230            Expr::Any(items) => format!("any({})", infix_bare(items)),
231            Expr::Not(e) => format!("!{}", e.describe()),
232            Expr::If {
233                when,
234                then,
235                otherwise,
236            } => format!(
237                "if {} then {} else {}",
238                when.describe(),
239                then.describe(),
240                otherwise.describe()
241            ),
242            Expr::Coalesce(items) => format!("coalesce({})", infix_bare(items)),
243            Expr::Concat(items) => format!("concat({})", infix_bare(items)),
244            Expr::CountTrue(keys) => format!("count_true({})", keys.join(", ")),
245        }
246    }
247}
248
249fn num(e: &Expr, state: &State) -> Option<f64> {
250    match e.eval(state)? {
251        Value::Number(n) => n.as_f64(),
252        _ => None,
253    }
254}
255
256fn truthy(e: &Expr, state: &State) -> bool {
257    matches!(e.eval(state), Some(Value::Bool(true)))
258}
259
260/// Render an f64 as a JSON number, preferring integer form when exact.
261fn number(n: f64) -> Value {
262    if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) {
263        json!(n as i64)
264    } else {
265        json!(n)
266    }
267}
268
269fn nary(items: &[Expr], state: &State, op: impl Fn(f64, f64) -> f64, init: f64) -> Option<Value> {
270    let mut acc = init;
271    for item in items {
272        acc = op(acc, num(item, state)?);
273    }
274    Some(number(acc))
275}
276
277fn fold_nums(items: &[Expr], state: &State, op: impl Fn(f64, f64) -> f64) -> Option<Value> {
278    let mut iter = items.iter();
279    let mut acc = num(iter.next()?, state)?;
280    for item in iter {
281        acc = op(acc, num(item, state)?);
282    }
283    Some(number(acc))
284}
285
286fn infix(items: &[Expr], sep: &str) -> String {
287    format!("({})", infix_sep(items, sep))
288}
289
290fn infix_bare(items: &[Expr]) -> String {
291    infix_sep(items, ", ")
292}
293
294fn infix_sep(items: &[Expr], sep: &str) -> String {
295    items
296        .iter()
297        .map(Expr::describe)
298        .collect::<Vec<_>>()
299        .join(sep)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn state_with(pairs: &[(&str, Value)]) -> State {
307        let state = State::new();
308        for (k, v) in pairs {
309            let _ = state.set(*k, v.clone());
310        }
311        state
312    }
313
314    #[test]
315    fn arithmetic_is_strict_on_missing_keys() {
316        let expr: Expr = serde_json::from_value(json!({
317            "add": [{"key": "a"}, {"const": 1}]
318        }))
319        .unwrap();
320        assert_eq!(expr.eval(&State::new()), None);
321        assert_eq!(
322            expr.eval(&state_with(&[("a", json!(2))])),
323            Some(json!(3)),
324            "integers stay integers"
325        );
326    }
327
328    #[test]
329    fn logic_is_total_on_missing_keys() {
330        let expr: Expr = serde_json::from_value(json!({
331            "any": [{"key": "missing"}, {"key": "set"}]
332        }))
333        .unwrap();
334        assert_eq!(
335            expr.eval(&state_with(&[("set", json!(true))])),
336            Some(json!(true))
337        );
338        assert_eq!(expr.eval(&State::new()), Some(json!(false)));
339
340        let not: Expr = serde_json::from_value(json!({"not": {"key": "missing"}})).unwrap();
341        assert_eq!(not.eval(&State::new()), Some(json!(true)));
342    }
343
344    #[test]
345    fn if_coalesce_concat_count() {
346        let state = state_with(&[
347            ("severity", json!("severe")),
348            ("a", json!(true)),
349            ("b", json!(false)),
350        ]);
351        let level: Expr = serde_json::from_value(json!({
352            "if": {
353                "when": {"eq": [{"key": "severity"}, {"const": "severe"}]},
354                "then": {"const": "high"},
355                "else": {"const": "normal"}
356            }
357        }))
358        .unwrap();
359        assert_eq!(level.eval(&state), Some(json!("high")));
360
361        let fallback: Expr = serde_json::from_value(json!({
362            "coalesce": [{"key": "nickname"}, {"key": "severity"}]
363        }))
364        .unwrap();
365        assert_eq!(fallback.eval(&state), Some(json!("severe")));
366
367        let line: Expr = serde_json::from_value(json!({
368            "concat": [{"const": "severity="}, {"key": "severity"}]
369        }))
370        .unwrap();
371        assert_eq!(line.eval(&state), Some(json!("severity=severe")));
372
373        let count: Expr = serde_json::from_value(json!({"count_true": ["a", "b", "c"]})).unwrap();
374        assert_eq!(count.eval(&state), Some(json!(1)));
375    }
376
377    #[test]
378    fn division_by_zero_is_none() {
379        let expr: Expr =
380            serde_json::from_value(json!({"div": [{"const": 1}, {"const": 0}]})).unwrap();
381        assert_eq!(expr.eval(&State::new()), None);
382    }
383
384    #[test]
385    fn comparisons_and_bounds() {
386        let state = state_with(&[("score", json!(0.8))]);
387        let gt: Expr =
388            serde_json::from_value(json!({"gt": [{"key": "score"}, {"const": 0.5}]})).unwrap();
389        assert_eq!(gt.eval(&state), Some(json!(true)));
390        let clamp: Expr = serde_json::from_value(json!({
391            "min": [{"key": "score"}, {"const": 0.6}]
392        }))
393        .unwrap();
394        assert_eq!(clamp.eval(&state), Some(json!(0.6)));
395    }
396
397    #[test]
398    fn keys_read_is_the_full_dependency_set() {
399        let expr: Expr = serde_json::from_value(json!({
400            "if": {
401                "when": {"any": [{"key": "a"}, {"not": {"key": "b"}}]},
402                "then": {"add": [{"key": "c"}, {"const": 1}]},
403                "else": {"count_true": ["d", "e"]}
404            }
405        }))
406        .unwrap();
407        let keys: Vec<String> = expr.keys_read().into_iter().collect();
408        assert_eq!(keys, ["a", "b", "c", "d", "e"]);
409    }
410
411    #[test]
412    fn reads_fall_back_to_derived_keys() {
413        let state = state_with(&[("derived:risk", json!(0.9))]);
414        let expr: Expr = serde_json::from_value(json!({"key": "risk"})).unwrap();
415        assert_eq!(expr.eval(&state), Some(json!(0.9)));
416    }
417
418    #[test]
419    fn serde_round_trips_and_describes() {
420        let doc = json!({
421            "add": [
422                {"mul": [{"const": 0.6}, {"key": "overdue"}]},
423                {"key": "penalty"}
424            ]
425        });
426        let expr: Expr = serde_json::from_value(doc.clone()).unwrap();
427        assert_eq!(serde_json::to_value(&expr).unwrap(), doc);
428        assert_eq!(expr.describe(), "((0.6 * overdue) + penalty)");
429    }
430}