lemma-engine 0.10.1

A pure, declarative language for business rules.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! End-to-end tests for strict veto semantics under normalization.
//!
//! Normalization may only delete a subexpression whose removal cannot change the
//! result: a total operand, or a literal `false` operand of `and`, which decides the
//! conjunction whatever the other operands are. These tests pin the contract that the
//! folded and unfolded forms of the same expression produce identical evaluation
//! results for all of: value present, value missing, and operand vetoes.

use indexmap::IndexMap;
use lemma::{DateTimeValue, Engine, EvaluateDataValue};
use std::time::Instant;

fn load(code: &str) -> Engine {
    let mut engine = Engine::new();
    engine
        .load([(lemma::SourceType::Volatile, code.to_string())])
        .expect("spec must load");
    engine
}

fn run(engine: &Engine, spec: &str, inputs: &[(&str, &str)]) -> lemma::Response {
    let now = DateTimeValue::now();
    let data: IndexMap<String, EvaluateDataValue> = inputs
        .iter()
        .map(|(name, value)| (name.to_string(), (*value).into()))
        .collect();
    engine
        .evaluate(None, spec, Some(&now), data, None, false)
        .expect("evaluation must succeed")
}

fn rule_result<'response>(
    response: &'response lemma::Response,
    rule_name: &str,
) -> &'response lemma::RuleResult {
    response
        .results
        .values()
        .find(|result| result.rule.name == rule_name)
        .unwrap_or_else(|| panic!("rule '{rule_name}' must be in the response"))
}

#[test]
fn multiply_by_zero_with_missing_data_vetoes() {
    let engine = load(
        r#"
spec strict_multiply
data x: number
rule r: x * 0
"#,
    );

    let missing = run(&engine, "strict_multiply", &[]);
    let result = rule_result(&missing, "r");
    assert!(
        result.vetoed,
        "x is missing: the multiply must veto, not fold to 0"
    );

    let supplied = run(&engine, "strict_multiply", &[("x", "7")]);
    let result = rule_result(&supplied, "r");
    assert!(!result.vetoed);
    assert_eq!(result.result(), Some("0"));
}

#[test]
fn multiply_measure_data_by_zero_keeps_unit() {
    let engine = load(
        r#"
spec strict_measure_multiply
data money: measure -> unit eur: 1
data price: money
rule r: price * 0
"#,
    );

    let response = run(&engine, "strict_measure_multiply", &[("price", "5 eur")]);
    let result = rule_result(&response, "r");
    assert!(!result.vetoed);
    assert_eq!(
        result.result(),
        Some("0 eur"),
        "the runtime multiply must keep the measure's unit"
    );

    let missing = run(&engine, "strict_measure_multiply", &[]);
    let result = rule_result(&missing, "r");
    assert!(result.vetoed, "price is missing: the multiply must veto");
}

#[test]
fn multiply_measure_literal_by_zero_folds_to_typed_zero() {
    let engine = load(
        r#"
spec folded_measure_multiply
data money: measure -> unit eur: 1
data rate: 5 eur
rule r: rate * 0
"#,
    );

    // `rate` is bound in the spec, so the inlined literal product folds at
    // planning time; the folded zero must keep the unit signature.
    let response = run(&engine, "folded_measure_multiply", &[]);
    let result = rule_result(&response, "r");
    assert!(!result.vetoed);
    assert_eq!(result.result(), Some("0 eur"));
}

#[test]
fn multiply_vetoing_rule_by_zero_propagates_the_veto() {
    let engine = load(
        r#"
spec strict_veto_multiply
rule blocked: veto "credit check failed"
rule r: blocked * 0
"#,
    );

    let response = run(&engine, "strict_veto_multiply", &[]);
    let result = rule_result(&response, "r");
    assert!(
        result.vetoed,
        "the inlined veto must propagate through the multiply"
    );
    assert_eq!(result.veto_reason.as_deref(), Some("credit check failed"));
}

#[test]
fn and_with_literal_false_is_false_without_missing_data() {
    let engine = load(
        r#"
spec strict_and
data flag: boolean
rule r: flag and (1 > 2)
"#,
    );

    // `1 > 2` constant-folds to `false`, which decides the conjunction whatever
    // `flag` is, so a missing `flag` does not veto.
    let missing = run(&engine, "strict_and", &[]);
    let result = rule_result(&missing, "r");
    assert!(!result.vetoed, "flag is not needed: false decides the and");
    assert_eq!(result.result(), Some("false"));

    let supplied = run(&engine, "strict_and", &[("flag", "yes")]);
    let result = rule_result(&supplied, "r");
    assert!(!result.vetoed);
    assert_eq!(result.result(), Some("false"));
}

#[test]
fn exp_of_log_keeps_domain_veto() {
    let engine = load(
        r#"
spec strict_exp_log
data offset: number
rule r: exp (log offset)
"#,
    );

    let negative = run(&engine, "strict_exp_log", &[("offset", "-5")]);
    let result = rule_result(&negative, "r");
    assert!(
        result.vetoed,
        "log of a negative operand must veto; the exp/log collapse would erase it"
    );

    let positive = run(&engine, "strict_exp_log", &[("offset", "5")]);
    let result = rule_result(&positive, "r");
    assert!(!result.vetoed);
}

#[test]
fn power_zero_with_missing_data_vetoes() {
    let engine = load(
        r#"
spec strict_power_zero
data x: number
rule r: x ^ 0
"#,
    );

    let missing = run(&engine, "strict_power_zero", &[]);
    let result = rule_result(&missing, "r");
    assert!(
        result.vetoed,
        "x is missing: the power must veto, not fold to 1"
    );

    let supplied = run(&engine, "strict_power_zero", &[("x", "9")]);
    let result = rule_result(&supplied, "r");
    assert!(!result.vetoed);
    assert_eq!(result.result(), Some("1"));
}

#[test]
fn self_doubling_rule_chain_shares_graph_cells() {
    // Each rule references its predecessor twice. Shared NormalForm graph
    // growth is linear in chain length — not Expression tree blowup.
    // Cell-count band is asserted in transitive_normalization_plan_shape::
    // doubling_chain_graph_stays_linear (crate-private normal_forms table).
    let n = 40;
    let mut code = String::from("\nspec doubling\ndata input: number\nrule r0: input > 0\n");
    for index in 1..=n {
        code.push_str(&format!(
            "rule r{index}: r{previous} and not r{previous}\n",
            previous = index - 1
        ));
    }

    let started = Instant::now();
    let mut engine = Engine::new();
    engine
        .load([(lemma::SourceType::Volatile, code.to_string())])
        .expect("shared-graph self-doubling chain must plan");
    let response = run(&engine, "doubling", &[("input", "1")]);
    let result = rule_result(&response, &format!("r{n}"));
    assert!(!result.vetoed);
    assert_eq!(result.result(), Some("false"));
    // Regression guard only (not a microbench). Pre-DAG-collect walks blew past
    // this at much smaller n (~18s at n=12).
    assert!(
        started.elapsed().as_secs_f64() < 2.0,
        "doubling n={n} load+run took {:?}; expected shared-graph path",
        started.elapsed()
    );
}

#[derive(Debug)]
enum Outcome {
    Result(&'static str),
    Missing(Vec<&'static str>),
    Veto(&'static str),
}

fn assert_outcome(result: &lemma::RuleResult, expected: &Outcome, context: &str) {
    match expected {
        Outcome::Result(value) => {
            assert!(
                !result.vetoed,
                "{context}: expected {value}, got {result:?}"
            );
            assert_eq!(result.result(), Some(*value), "{context}");
        }
        Outcome::Missing(names) => {
            assert!(
                result.vetoed,
                "{context}: expected missing data, got {result:?}"
            );
            let mut actual = result.missing_data().to_vec();
            actual.sort();
            let mut wanted: Vec<String> = names.iter().map(|name| name.to_string()).collect();
            wanted.sort();
            assert_eq!(actual, wanted, "{context}");
        }
        Outcome::Veto(reason) => {
            assert!(result.vetoed, "{context}: expected veto, got {result:?}");
            assert_eq!(result.veto_reason.as_deref(), Some(*reason), "{context}");
            assert!(result.missing_data().is_empty(), "{context}");
        }
    }
}

const RESIDUAL_FIRST_FOLDED: &str = r#"
spec order
data model: text
data extra: boolean
data flag_c: boolean
data amount: number
rule r: 0
  unless model is "b" and extra then amount
  unless flag_c and model is "c" then 3
"#;

const RESIDUAL_FIRST_UNFOLDED: &str = r#"
spec order
data model: text
data extra: boolean
data flag_c: boolean
data amount: number
rule r: 0
  unless model is "b" and extra then amount
  unless flag_c and model is "c" then 3
  unless model is "z" then 9
"#;

#[test]
fn residual_first_conjunction_folded_and_unfolded_agree() {
    let cases: [(&[(&str, &str)], Outcome); 6] = [
        (
            &[("model", "b"), ("extra", "true"), ("amount", "5")],
            Outcome::Result("5"),
        ),
        (
            &[("model", "b"), ("extra", "true")],
            Outcome::Missing(vec!["amount"]),
        ),
        (&[("model", "b"), ("extra", "false")], Outcome::Result("0")),
        (&[("model", "c")], Outcome::Missing(vec!["flag_c"])),
        (&[("model", "c"), ("flag_c", "false")], Outcome::Result("0")),
        (&[("model", "c"), ("flag_c", "true")], Outcome::Result("3")),
    ];
    for (form, source) in [
        ("folded", RESIDUAL_FIRST_FOLDED),
        ("unfolded", RESIDUAL_FIRST_UNFOLDED),
    ] {
        let engine = load(source);
        for (inputs, expected) in &cases {
            let response = run(&engine, "order", inputs);
            let result = rule_result(&response, "r");
            let context = format!("{form} {inputs:?}");
            match (form, expected) {
                // The unfolded piecewise keeps lower-priority arms live while an
                // undecided arm is unknown, so its missing list may add data.
                ("unfolded", Outcome::Missing(names)) => {
                    assert!(
                        result.vetoed,
                        "{context}: expected missing data, got {result:?}"
                    );
                    for name in names {
                        assert!(
                            result.missing_data().iter().any(|actual| actual == name),
                            "{context}: missing list {:?} does not include {name}",
                            result.missing_data()
                        );
                    }
                }
                _ => assert_outcome(result, expected, &context),
            }
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
enum OperandKind {
    True,
    False,
    Missing,
    Veto,
}

/// Operand text for the matrix spec. `Veto` operands are the rule `blocked`.
fn operand_expression(kind: OperandKind, name: &'static str) -> &'static str {
    match kind {
        OperandKind::Veto => "blocked",
        OperandKind::True | OperandKind::False | OperandKind::Missing => name,
    }
}

/// Expected outcome of `first and second`, in evaluation order. A false operand
/// decides regardless of position. Otherwise the first missing or vetoed operand in
/// source order propagates.
fn expected_and(
    first: (OperandKind, &'static str),
    second: (OperandKind, &'static str),
) -> Outcome {
    let operands = [first, second];
    if operands.iter().any(|(kind, _)| *kind == OperandKind::False) {
        return Outcome::Result("false");
    }
    for (kind, _) in operands {
        match kind {
            OperandKind::True => {}
            OperandKind::False => unreachable!("BUG: false operand decided above"),
            OperandKind::Missing => {
                let names = operands
                    .iter()
                    .filter(|(kind, _)| *kind == OperandKind::Missing)
                    .map(|(_, name)| *name)
                    .collect();
                return Outcome::Missing(names);
            }
            OperandKind::Veto => return Outcome::Veto("blocked"),
        }
    }
    Outcome::Result("true")
}

#[test]
fn and_is_order_independent_for_every_operand_kind_pair() {
    let kinds = [
        OperandKind::True,
        OperandKind::False,
        OperandKind::Missing,
        OperandKind::Veto,
    ];
    for first_kind in kinds {
        for second_kind in kinds {
            let first = (first_kind, "a");
            let second = (second_kind, "b");
            let engine = load(&format!(
                r#"
spec matrix
data a: boolean
data b: boolean
rule blocked: veto "blocked"
rule forward: {} and {}
rule backward: {} and {}
"#,
                operand_expression(first_kind, "a"),
                operand_expression(second_kind, "b"),
                operand_expression(second_kind, "b"),
                operand_expression(first_kind, "a"),
            ));
            let mut inputs: Vec<(&str, &str)> = Vec::new();
            match first_kind {
                OperandKind::True => inputs.push(("a", "true")),
                OperandKind::False => inputs.push(("a", "false")),
                OperandKind::Missing | OperandKind::Veto => {}
            }
            match second_kind {
                OperandKind::True => inputs.push(("b", "true")),
                OperandKind::False => inputs.push(("b", "false")),
                OperandKind::Missing | OperandKind::Veto => {}
            }
            let response = run(&engine, "matrix", &inputs);
            assert_outcome(
                rule_result(&response, "forward"),
                &expected_and(first, second),
                &format!("forward {first_kind:?} and {second_kind:?}"),
            );
            assert_outcome(
                rule_result(&response, "backward"),
                &expected_and(second, first),
                &format!("backward {second_kind:?} and {first_kind:?}"),
            );
        }
    }
}