use lemma::{DateTimeValue, Engine, RuleResult};
use std::collections::HashMap;
use std::sync::Arc;
fn load(engine: &mut Engine, code: &str) {
engine
.load([(lemma::SourceType::Volatile, code.to_string())])
.expect("spec must load");
}
fn run(
engine: &Engine,
spec: &str,
data: HashMap<String, String>,
rules: Option<&[String]>,
explain: bool,
) -> lemma::Response {
let now = DateTimeValue::now();
engine
.run(None, spec, Some(&now), data, rules, explain)
.expect("evaluation must succeed")
}
fn rule<'a>(response: &'a lemma::Response, name: &str) -> &'a RuleResult {
response.results.get(name).unwrap_or_else(|| {
panic!(
"rule '{name}' missing from results: {:?}",
response.results.keys().collect::<Vec<_>>()
)
})
}
fn missing<'a>(response: &'a lemma::Response, name: &str) -> &'a [String] {
rule(response, name).missing_data.as_slice()
}
fn show_has(engine: &Engine, spec: &str, key: &str) {
let now = DateTimeValue::now();
let show = engine.show(None, spec, Some(&now)).expect("show");
assert!(
show.data.contains_key(key),
"show.data must contain {key:?}; keys={:?}",
show.data.keys().collect::<Vec<_>>()
);
}
#[test]
fn h1_and_short_circuit_does_not_release_path_reachable_via_default_body() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data flag: boolean
data expensive: number
rule main: expensive
unless flag and (expensive > 100) then 0
"#,
);
let mut data = HashMap::new();
data.insert("flag".to_string(), "false".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
md.contains(&"expensive".to_string()),
"expensive is still needed via default body; must not be released when flag=false: {md:?}"
);
}
#[test]
fn h2_nested_and_first_false_releases_all_right_operands() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: boolean
data b: boolean
data c: boolean
rule main: a and b and c
"#,
);
let mut data = HashMap::new();
data.insert("a".to_string(), "false".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"b".to_string()),
"b must be released when a=false (outer And short-circuits): {md:?}"
);
assert!(
!md.contains(&"c".to_string()),
"c must be released when a=false (outer And short-circuits): {md:?}"
);
}
#[test]
fn h3_nested_and_middle_false_releases_rightmost_only() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: boolean
data b: boolean
data c: boolean
rule main: a and b and c
"#,
);
let mut data = HashMap::new();
data.insert("a".to_string(), "true".to_string());
data.insert("b".to_string(), "false".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"c".to_string()),
"c must be released when b=false (inner And short-circuits): {md:?}"
);
assert!(
!md.contains(&"a".to_string()),
"a is supplied, must not appear: {md:?}"
);
assert!(
!md.contains(&"b".to_string()),
"b is supplied, must not appear: {md:?}"
);
}
#[test]
fn h4_piecewise_taken_arm_releases_shared_body_all_routes_dead() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data shared: number
data flag_a: boolean
data flag_b: boolean
rule main: shared
unless flag_a then shared
unless flag_b then 0
"#,
);
let mut data = HashMap::new();
data.insert("flag_b".to_string(), "true".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"shared".to_string()),
"all routes to shared are dead when flag_b arm is taken; must be released: {md:?}"
);
assert!(
!md.contains(&"flag_a".to_string()),
"flag_a's arm is dead when flag_b arm is taken: {md:?}"
);
}
#[test]
fn h5_piecewise_arm_not_taken_arm_body_absent_from_missing_data() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data flag: boolean
data expensive: number
rule main: 1
unless flag then expensive
"#,
);
let mut data = HashMap::new();
data.insert("flag".to_string(), "false".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"expensive".to_string()),
"flag=false → default wins → arm body expensive must be released: {md:?}"
);
}
#[test]
fn h6_two_control_nodes_each_blocking_one_route_expensive_released() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data flag_outer: boolean
data flag_inner: boolean
data expensive: number
rule guarded: expensive
unless flag_inner then 0
rule main: expensive
unless flag_outer then guarded
"#,
);
let mut data = HashMap::new();
data.insert("flag_outer".to_string(), "true".to_string());
data.insert("flag_inner".to_string(), "true".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"expensive".to_string()),
"both routes to expensive are blocked by two independent piecewise decisions; \
expensive must be released — not over-reported as missing: {md:?}"
);
}
#[test]
fn a1_full_overlay_all_missing_data_empty() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
data b: number
rule main: a + b
"#,
);
let mut data = HashMap::new();
data.insert("a".to_string(), "1".to_string());
data.insert("b".to_string(), "2".to_string());
let response = run(&engine, "demo", data, None, false);
let main = rule(&response, "main");
assert!(
!main.vetoed,
"must not missing-data veto: {:?}",
main.veto_reason
);
assert!(
main.missing_data.is_empty(),
"expected empty missing_data, got {:?}",
main.missing_data
);
}
#[test]
fn a2_no_inputs_lists_all_operands_in_declaration_order() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
data b: number
data c: number
rule main: a + b + c
"#,
);
let response = run(&engine, "demo", HashMap::new(), None, false);
assert_eq!(missing(&response, "main"), &["a", "b", "c"]);
}
#[test]
fn a3_partial_overlay_omits_supplied_keys() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
data b: number
data c: number
rule main: a + b + c
"#,
);
let mut data = HashMap::new();
data.insert("a".to_string(), "1".to_string());
let response = run(&engine, "demo", data, None, false);
assert_eq!(missing(&response, "main"), &["b", "c"]);
}
#[test]
fn a4_prefill_absent_suggest_present_in_missing_data() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data prefilled: 10
data suggested: number -> suggest 5
data required: number
rule main: prefilled + suggested + required
"#,
);
let response = run(&engine, "demo", HashMap::new(), None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"prefilled".to_string()),
"prefill must not be missing: {md:?}"
);
assert!(
md.contains(&"suggested".to_string()),
"suggest unbound must be missing: {md:?}"
);
assert!(
md.contains(&"required".to_string()),
"required unbound must be missing: {md:?}"
);
}
#[test]
fn b1_two_rules_do_not_cross_leak_missing_data() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data x: number
data y: number
rule r1: x
rule r2: y
"#,
);
let response = run(
&engine,
"demo",
HashMap::new(),
Some(&["r1".to_string(), "r2".to_string()]),
false,
);
assert_eq!(missing(&response, "r1"), &["x"]);
assert_eq!(missing(&response, "r2"), &["y"]);
}
#[test]
fn b2_request_only_r1_excludes_y_from_results() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data x: number
data y: number
rule r1: x
rule r2: y
"#,
);
let response = run(
&engine,
"demo",
HashMap::new(),
Some(&["r1".to_string()]),
false,
);
assert!(response.results.contains_key("r1"), "r1 must be present");
assert!(
!response.results.contains_key("r2"),
"r2 must not be in results when unrequested"
);
assert_eq!(missing(&response, "r1"), &["x"]);
}
#[test]
fn b3_shared_unbound_input_listed_on_both_rules() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data s: number
data t: number
rule r1: s + 1
rule r2: s + t
"#,
);
let response = run(
&engine,
"demo",
HashMap::new(),
Some(&["r1".to_string(), "r2".to_string()]),
false,
);
assert!(
missing(&response, "r1").contains(&"s".to_string()),
"r1 must list shared s: {:?}",
missing(&response, "r1")
);
assert!(
missing(&response, "r2").contains(&"s".to_string()),
"r2 must list shared s (memo replay): {:?}",
missing(&response, "r2")
);
assert!(
missing(&response, "r2").contains(&"t".to_string()),
"r2 must list t: {:?}",
missing(&response, "r2")
);
let mut data = HashMap::new();
data.insert("s".to_string(), "3".to_string());
let response = run(
&engine,
"demo",
data,
Some(&["r1".to_string(), "r2".to_string()]),
false,
);
assert!(
!missing(&response, "r1").contains(&"s".to_string()),
"r1 must not list supplied s: {:?}",
missing(&response, "r1")
);
assert!(
!missing(&response, "r2").contains(&"s".to_string()),
"r2 must not list supplied s: {:?}",
missing(&response, "r2")
);
assert_eq!(missing(&response, "r2"), &["t"]);
}
#[test]
fn b4_only_incomplete_sibling_has_non_empty_missing_data() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data ready: 1
data need: number
rule ok: ready
rule incomplete: need
"#,
);
let response = run(&engine, "demo", HashMap::new(), None, false);
assert!(
missing(&response, "ok").is_empty(),
"ok must have empty missing_data: {:?}",
missing(&response, "ok")
);
assert_eq!(missing(&response, "incomplete"), &["need"]);
}
const CHOOSER: &str = r#"
spec chooser
data mode: text -> options "simple" "complex"
data simple_input: number
data complex_input_a: number
data complex_input_b: number
rule result: veto "pick mode"
unless mode is "simple" then simple_input
unless mode is "complex" then complex_input_a + complex_input_b
"#;
#[test]
fn c1_mode_unbound_lists_mode_and_live_arm_inputs() {
let mut engine = Engine::new();
load(&mut engine, CHOOSER);
let response = run(
&engine,
"chooser",
HashMap::new(),
Some(&["result".to_string()]),
false,
);
let md = missing(&response, "result");
assert!(md.contains(&"mode".to_string()), "mode: {md:?}");
assert!(
md.contains(&"simple_input".to_string()),
"default/simple live: {md:?}"
);
assert!(
md.contains(&"complex_input_a".to_string()),
"complex arm live while mode unbound: {md:?}"
);
assert!(
md.contains(&"complex_input_b".to_string()),
"complex arm live while mode unbound: {md:?}"
);
}
#[test]
fn c2_mode_simple_prunes_complex_inputs() {
let mut engine = Engine::new();
load(&mut engine, CHOOSER);
let mut data = HashMap::new();
data.insert("mode".to_string(), "simple".to_string());
let response = run(
&engine,
"chooser",
data,
Some(&["result".to_string()]),
false,
);
let md = missing(&response, "result");
assert!(
!md.contains(&"complex_input_a".to_string()),
"complex pruned: {md:?}"
);
assert!(
!md.contains(&"complex_input_b".to_string()),
"complex pruned: {md:?}"
);
assert!(
md.contains(&"simple_input".to_string()),
"simple_input still missing: {md:?}"
);
assert!(!md.contains(&"mode".to_string()), "mode supplied: {md:?}");
}
#[test]
fn c3_mode_complex_prunes_simple_input() {
let mut engine = Engine::new();
load(&mut engine, CHOOSER);
let mut data = HashMap::new();
data.insert("mode".to_string(), "complex".to_string());
let response = run(
&engine,
"chooser",
data,
Some(&["result".to_string()]),
false,
);
let md = missing(&response, "result");
assert!(
!md.contains(&"simple_input".to_string()),
"simple pruned: {md:?}"
);
assert!(md.contains(&"complex_input_a".to_string()), "{md:?}");
assert!(md.contains(&"complex_input_b".to_string()), "{md:?}");
}
#[test]
fn c4_unless_wins_releases_default_arm_data() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data expensive: number
data threshold: number
rule main: expensive * 2
unless now > 1970-01-01 then threshold + 1
"#,
);
let mut data = HashMap::new();
data.insert("threshold".to_string(), "4".to_string());
let response = run(&engine, "demo", data, Some(&["main".to_string()]), false);
let md = missing(&response, "main");
assert!(
!md.contains(&"expensive".to_string()),
"unless win must release default-only expensive: {md:?}"
);
assert!(
!md.contains(&"threshold".to_string()),
"threshold supplied: {md:?}"
);
assert!(
md.is_empty(),
"no unbound live inputs when unless wins with threshold: {md:?}"
);
}
#[test]
fn c5_statically_dead_unless_arm_data_absent() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data flag: boolean
rule main: 1
unless flag and (1 > 2) then 2
"#,
);
let response = run(&engine, "demo", HashMap::new(), None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"flag".to_string()),
"statically dead unless must not require flag: {md:?}"
);
assert!(
!response.results["main"].vetoed,
"statically collapsed default must evaluate: {:?}",
response.results["main"]
);
}
#[test]
fn d1_false_and_does_not_list_right_operand() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data flag: boolean
data expensive: number
rule main: flag and expensive > 0
"#,
);
let mut data = HashMap::new();
data.insert("flag".to_string(), "false".to_string());
let response = run(&engine, "demo", data, None, false);
let md = missing(&response, "main");
assert!(
!md.contains(&"expensive".to_string()),
"false and must not need expensive: {md:?}"
);
}
#[test]
fn d2_left_missing_still_lists_remaining_operands() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
data b: number
data c: number
rule main: a + b + c
"#,
);
let response = run(&engine, "demo", HashMap::new(), None, false);
let md = missing(&response, "main");
assert!(md.contains(&"a".to_string()), "{md:?}");
assert!(md.contains(&"b".to_string()), "{md:?}");
assert!(md.contains(&"c".to_string()), "{md:?}");
}
#[test]
fn e1_rule_embed_surfaces_inner_unbound_on_outer_missing_data() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec inner
data slot: number
rule half: slot / 2
spec outer
uses i: inner
rule r: i.half + 1
"#,
);
let response = run(&engine, "outer", HashMap::new(), None, false);
let md = missing(&response, "r");
assert!(
md.iter()
.any(|k| k == "i.slot" || k.ends_with(".slot") || k == "slot"),
"outer must list inner unbound slot via embed: {md:?}"
);
}
#[test]
fn e2_with_rule_ref_binding_omitted_from_missing_data() {
let code = r#"
spec bag
data weight: number
data item_cost: number
rule total: weight * item_cost
spec calc
uses bag
with bag.item_cost: item_cost
data type_of_nut: text -> options "peanut" "cashew"
rule item_cost: 1
unless type_of_nut is "cashew" then 2
rule total: bag.total
"#;
let mut engine = Engine::new();
engine
.load([(
lemma::SourceType::Path(Arc::new(std::path::PathBuf::from("with_bind.lemma"))),
code.to_string(),
)])
.expect("load");
let response = run(
&engine,
"calc",
HashMap::new(),
Some(&["total".to_string()]),
false,
);
let md = missing(&response, "total");
assert!(
!md.contains(&"bag.item_cost".to_string()),
"with-bound rule ref must not appear: {md:?}"
);
}
#[test]
fn e3_nested_input_key_matches_show_data_key() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec inner
data slot: number
rule half: slot / 2
spec outer
uses i: inner
rule r: i.half
"#,
);
let response = run(&engine, "outer", HashMap::new(), None, false);
let md = missing(&response, "r");
assert!(!md.is_empty(), "expected nested missing key: {md:?}");
for key in md {
show_has(&engine, "outer", key);
}
}
#[test]
fn f1_explain_true_false_same_missing_data_and_outcomes() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
data b: number
rule main: a + b
"#,
);
let mut data = HashMap::new();
data.insert("a".to_string(), "1".to_string());
let plain = run(&engine, "demo", data.clone(), None, false);
let explained = run(&engine, "demo", data, None, true);
assert_eq!(
missing(&plain, "main"),
missing(&explained, "main"),
"missing_data must match"
);
let p = rule(&plain, "main");
let e = rule(&explained, "main");
assert_eq!(p.vetoed, e.vetoed);
assert_eq!(p.display(), e.display());
assert_eq!(p.veto_reason, e.veto_reason);
}
#[test]
fn f2_fully_supplied_with_explain_missing_data_empty() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
rule main: a
"#,
);
let mut data = HashMap::new();
data.insert("a".to_string(), "9".to_string());
let response = run(&engine, "demo", data, None, true);
assert!(
missing(&response, "main").is_empty(),
"{:?}",
missing(&response, "main")
);
}
#[test]
fn g2_every_missing_data_key_exists_in_show() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec demo
data a: number
data b: number
rule main: a + b
"#,
);
let response = run(&engine, "demo", HashMap::new(), None, false);
let md = missing(&response, "main");
assert_eq!(md, &["a", "b"]);
for key in md {
show_has(&engine, "demo", key);
}
}
#[test]
fn g2b_reference_bound_chain_missing_data_key_exists_in_show() {
let mut engine = Engine::new();
load(
&mut engine,
r#"
spec prev
data code: text -> options "AA" "BB"
rule name: veto "no code"
unless code is "AA" then "Alpha"
unless code is "BB" then "Beta"
spec current
uses prev
data code: prev.code -> option "SS"
with prev.code: code
rule name: prev.name
"#,
);
let response = run(
&engine,
"current",
HashMap::new(),
Some(&["name".to_string()]),
false,
);
let md = missing(&response, "name");
let now = DateTimeValue::now();
let show = engine.show(None, "current", Some(&now)).expect("show");
let show_keys: Vec<_> = show.data.keys().collect();
assert!(
md.contains(&"code".to_string()),
"promptable input 'code' must appear in missing_data: {md:?}"
);
assert!(
!md.contains(&"prev.code".to_string()),
"internal reference 'prev.code' must NOT appear in missing_data: {md:?}"
);
assert!(
show.data.contains_key("code"),
"BUG: show.data must contain 'code' (the ultimate promptable target)\n\
missing_data={md:?}\n\
show.data.keys={show_keys:?}\n\
These must agree: every missing_data key must exist in show.data"
);
let name = rule(&response, "name");
assert!(name.vetoed, "unbound code must veto name");
let reason = name.veto_reason.as_deref().expect("veto reason");
assert!(
reason.contains("Missing data: code"),
"veto must name promptable input key 'code', not internal reference path; got: {reason}"
);
assert!(
!reason.contains("prev"),
"veto must not expose internal reference path; got: {reason}"
);
}
#[test]
fn g3_missing_data_order_follows_plan_data_declaration() {
let mut engine = Engine::new();
load(&mut engine, CHOOSER);
let response = run(
&engine,
"chooser",
HashMap::new(),
Some(&["result".to_string()]),
false,
);
assert_eq!(
missing(&response, "result"),
&["mode", "simple_input", "complex_input_a", "complex_input_b"]
);
}