use lemma::{DateTimeValue, Engine};
use std::collections::HashMap;
fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
DateTimeValue {
year,
month,
day,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
}
}
fn eval(engine: &Engine, spec_name: &str, effective: &DateTimeValue) -> lemma::Response {
engine
.run(None, spec_name, Some(effective), HashMap::new(), false)
.unwrap()
}
fn eval_with(
engine: &Engine,
spec_name: &str,
effective: &DateTimeValue,
data: Vec<(&str, &str)>,
) -> lemma::Response {
let map: HashMap<String, String> = data
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
engine
.run(None, spec_name, Some(effective), map, false)
.unwrap()
}
fn assert_rule_value(response: &lemma::Response, rule: &str, expected: &str) {
let result = response
.results
.get(rule)
.unwrap_or_else(|| panic!("rule '{}' not in results", rule));
let val = result
.result
.value()
.unwrap_or_else(|| panic!("rule '{}' is Veto, expected Value", rule));
assert_eq!(
val.to_string(),
expected,
"rule '{}': expected {}, got {}",
rule,
expected,
val
);
}
#[test]
fn single_unversioned_dependency() {
let mut engine = Engine::new();
engine
.load(
"spec config\ndata base_rate: 100",
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"config.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec pricing 2025-01-01
uses cfg: config
rule rate: cfg.base_rate * 2
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"pricing.lemma",
))),
)
.unwrap();
for d in [date(2025, 1, 15), date(2025, 7, 1), date(2025, 12, 15)] {
assert_rule_value(&eval(&engine, "pricing", &d), "rate", "200");
}
}
#[test]
fn one_boundary_produces_two_slices() {
let mut engine = Engine::new();
engine
.load(
r#"
spec config
data base_rate: 100
spec config 2025-04-01
data base_rate: 200
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"config.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec pricing 2025-01-01
uses cfg: config
rule rate: cfg.base_rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"pricing.lemma",
))),
)
.unwrap();
assert_rule_value(&eval(&engine, "pricing", &date(2025, 2, 1)), "rate", "100");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 3, 31)), "rate", "100");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 4, 1)), "rate", "200");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 6, 15)), "rate", "200");
}
#[test]
fn boundary_exactly_at_spec_effective_from_no_split() {
let mut engine = Engine::new();
engine
.load(
r#"
spec config
data rate: 50
spec config 2025-01-01
data rate: 75
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"config.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec pricing 2025-01-01
uses cfg: config
rule rate: cfg.rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"pricing.lemma",
))),
)
.unwrap();
assert_rule_value(&eval(&engine, "pricing", &date(2025, 1, 1)), "rate", "75");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 5, 1)), "rate", "75");
}
#[test]
fn three_versions_produce_three_slices() {
let mut engine = Engine::new();
engine
.load(
r#"
spec rates
data rate: 10
spec rates 2025-03-01
data rate: 20
spec rates 2025-07-01
data rate: 30
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("rates.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec pricing 2025-01-01
uses r: rates
data quantity: number
rule total: quantity * r.rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"pricing.lemma",
))),
)
.unwrap();
let cases = [
(date(2025, 2, 1), "100"), (date(2025, 5, 1), "200"), (date(2025, 9, 1), "300"), ];
for (d, expected) in &cases {
assert_rule_value(
&eval_with(&engine, "pricing", d, vec![("quantity", "10")]),
"total",
expected,
);
}
}
#[test]
fn four_versions_only_two_boundaries_inside_range() {
let mut engine = Engine::new();
engine
.load(
r#"
spec rates
data rate: 10
spec rates 2025-03-01
data rate: 20
spec rates 2025-06-01
data rate: 30
spec rates 2025-09-01
data rate: 40
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("rates.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec pricing 2025-04-01
uses r: rates
rule rate: r.rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"pricing.lemma",
))),
)
.unwrap();
assert_rule_value(&eval(&engine, "pricing", &date(2025, 4, 15)), "rate", "20");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 5, 31)), "rate", "20");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 6, 1)), "rate", "30");
assert_rule_value(&eval(&engine, "pricing", &date(2025, 7, 15)), "rate", "30");
}
#[test]
fn two_deps_boundaries_at_different_times() {
let mut engine = Engine::new();
engine
.load(
r#"
spec tax_rates
data vat: 19
spec tax_rates 2025-04-01
data vat: 21
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"tax_rates.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec shipping_rates
data fee: 5
spec shipping_rates 2025-07-01
data fee: 8
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"shipping_rates.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec invoice 2025-01-01
uses tax: tax_rates
uses shipping: shipping_rates
data price: number
rule vat_amount: price * tax.vat / 100
rule shipping_fee: shipping.fee
rule total: price + vat_amount + shipping_fee
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"invoice.lemma",
))),
)
.unwrap();
let r = eval_with(
&engine,
"invoice",
&date(2025, 2, 1),
vec![("price", "100")],
);
assert_rule_value(&r, "vat_amount", "19");
assert_rule_value(&r, "shipping_fee", "5");
assert_rule_value(&r, "total", "124");
let r = eval_with(
&engine,
"invoice",
&date(2025, 5, 1),
vec![("price", "100")],
);
assert_rule_value(&r, "vat_amount", "21");
assert_rule_value(&r, "shipping_fee", "5");
assert_rule_value(&r, "total", "126");
let r = eval_with(
&engine,
"invoice",
&date(2025, 9, 1),
vec![("price", "100")],
);
assert_rule_value(&r, "vat_amount", "21");
assert_rule_value(&r, "shipping_fee", "8");
assert_rule_value(&r, "total", "129");
}
#[test]
fn two_deps_boundaries_at_same_time() {
let mut engine = Engine::new();
engine
.load(
r#"
spec tax_rates
data vat: 19
spec tax_rates 2025-04-01
data vat: 21
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"tax_rates.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec shipping_rates
data fee: 5
spec shipping_rates 2025-04-01
data fee: 8
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"shipping_rates.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec invoice 2025-01-01
uses tax: tax_rates
uses shipping: shipping_rates
rule combined: tax.vat + shipping.fee
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"invoice.lemma",
))),
)
.unwrap();
assert_rule_value(
&eval(&engine, "invoice", &date(2025, 2, 1)),
"combined",
"24",
);
assert_rule_value(
&eval(&engine, "invoice", &date(2025, 6, 1)),
"combined",
"29",
);
}
#[test]
fn one_dep_versioned_one_dep_unversioned() {
let mut engine = Engine::new();
engine
.load(
r#"
spec constants
data pi: 3
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"constants.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec rates
data multiplier: 2
spec rates 2025-06-01
data multiplier: 4
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("rates.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec calc 2025-01-01
uses c: constants
uses r: rates
rule result: c.pi * r.multiplier
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("calc.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "calc", &date(2025, 3, 1)), "result", "6");
assert_rule_value(&eval(&engine, "calc", &date(2025, 9, 1)), "result", "12");
}
#[test]
fn transitive_two_levels_deep() {
let mut engine = Engine::new();
engine
.load(
r#"
spec base_rates
data multiplier: 2
spec base_rates 2025-06-01
data multiplier: 3
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"base_rates.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec intermediate
uses base: base_rates
data value: 10
rule adjusted: value * base.multiplier
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"intermediate.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec top 2025-01-01
uses mid: intermediate
rule result: mid.adjusted
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("top.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "top", &date(2025, 3, 1)), "result", "20");
assert_rule_value(&eval(&engine, "top", &date(2025, 9, 1)), "result", "30");
}
#[test]
fn transitive_both_levels_versioned() {
let mut engine = Engine::new();
engine
.load(
r#"
spec deep
data factor: 2
spec deep 2025-06-01
data factor: 5
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("deep.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec middle
uses d: deep
data base: 10
rule value: base * d.factor
spec middle 2025-04-01
uses d: deep
data base: 100
rule value: base * d.factor
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"middle.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec top 2025-01-01
uses m: middle
rule result: m.value
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("top.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "top", &date(2025, 2, 1)), "result", "20");
assert_rule_value(&eval(&engine, "top", &date(2025, 5, 1)), "result", "200");
assert_rule_value(&eval(&engine, "top", &date(2025, 9, 1)), "result", "500");
}
#[test]
fn diamond_dependency_single_boundary() {
let mut engine = Engine::new();
engine
.load(
r#"
spec shared
data value: 10
spec shared 2025-06-01
data value: 20
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"shared.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec left_branch
uses s: shared
rule doubled: s.value * 2
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("left.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec right_branch
uses s: shared
rule tripled: s.value * 3
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("right.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec top 2025-01-01
uses l: left_branch
uses r: right_branch
rule total: l.doubled + r.tripled
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("top.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "top", &date(2025, 3, 1)), "total", "50");
assert_rule_value(&eval(&engine, "top", &date(2025, 9, 1)), "total", "100");
}
#[test]
fn diamond_dependency_boundaries_at_different_levels() {
let mut engine = Engine::new();
engine
.load(
r#"
spec shared
data base: 10
spec shared 2025-08-01
data base: 50
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"shared.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec left
uses s: shared
data add: 1
rule result: s.base + add
spec left 2025-04-01
uses s: shared
data add: 2
rule result: s.base + add
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("left.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec right
uses s: shared
rule result: s.base * 2
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("right.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec top 2025-01-01
uses l: left
uses r: right
rule total: l.result + r.result
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("top.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "top", &date(2025, 2, 1)), "total", "31");
assert_rule_value(&eval(&engine, "top", &date(2025, 5, 1)), "total", "32");
assert_rule_value(&eval(&engine, "top", &date(2025, 9, 1)), "total", "152");
}
#[test]
fn unranged_spec_sliced_by_versioned_dep() {
let mut engine = Engine::new();
engine
.load(
r#"
spec rates
data tax: 19
spec rates 2026-01-01
data tax: 21
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("rates.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec calculator
uses r: rates
data income: number
rule tax_amount: income * r.tax / 100
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"calculator.lemma",
))),
)
.unwrap();
assert_rule_value(
&eval_with(
&engine,
"calculator",
&date(2025, 6, 1),
vec![("income", "1000")],
),
"tax_amount",
"190",
);
assert_rule_value(
&eval_with(
&engine,
"calculator",
&date(2026, 6, 1),
vec![("income", "1000")],
),
"tax_amount",
"210",
);
}
#[test]
fn three_versions_seamlessly_chained() {
let mut engine = Engine::new();
engine
.load(
r#"
spec policy 2025-01-01
data limit: 1000
spec policy 2025-04-01
data limit: 2000
spec policy 2025-08-01
data limit: 3000
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"policy.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec contract 2025-01-01
uses p: policy
data amount: number
rule under_limit: amount < p.limit
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"contract.lemma",
))),
)
.unwrap();
assert_rule_value(
&eval_with(
&engine,
"contract",
&date(2025, 2, 1),
vec![("amount", "1500")],
),
"under_limit",
"false",
);
assert_rule_value(
&eval_with(
&engine,
"contract",
&date(2025, 5, 1),
vec![("amount", "1500")],
),
"under_limit",
"true",
);
assert_rule_value(
&eval_with(
&engine,
"contract",
&date(2025, 9, 1),
vec![("amount", "1500")],
),
"under_limit",
"true",
);
}
#[test]
fn evaluate_at_boundary_instant_uses_new_version() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep
data val: 1
spec dep 2025-06-01
data val: 2
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec main
uses d: dep
rule result: d.val
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("main.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "main", &date(2025, 5, 31)), "result", "1");
assert_rule_value(&eval(&engine, "main", &date(2025, 6, 1)), "result", "2");
}
#[test]
fn third_level_spec_depends_on_versioned_spec() {
let mut engine = Engine::new();
engine
.load(
r#"
spec rates
data base: 100
spec rates 2025-05-01
data base: 200
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("rates.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec policy
uses r: rates
rule threshold: r.base * 2
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"policy.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec contract 2025-01-01
uses p: policy
data amount: number
rule is_over_threshold: amount > p.threshold
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"contract.lemma",
))),
)
.unwrap();
assert_rule_value(
&eval_with(
&engine,
"contract",
&date(2025, 3, 1),
vec![("amount", "250")],
),
"is_over_threshold",
"true",
);
assert_rule_value(
&eval_with(
&engine,
"contract",
&date(2025, 9, 1),
vec![("amount", "250")],
),
"is_over_threshold",
"false",
);
}
#[test]
fn realistic_tax_and_labor_law_scenario() {
let mut engine = Engine::new();
engine
.load(
r#"
spec tax_law
data income_tax_rate: 30
spec tax_law 2025-04-01
data income_tax_rate: 32
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("tax.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec labor_law
data min_wage_hourly: 12
data max_weekly_hours: 40
spec labor_law 2025-07-01
data min_wage_hourly: 15
data max_weekly_hours: 38
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("labor.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec employment 2025-01-01
uses tax: tax_law
uses labor: labor_law
data hourly_rate: number
data weekly_hours: number
rule annual_gross: hourly_rate * weekly_hours * 52
rule annual_tax: annual_gross * tax.income_tax_rate / 100
rule annual_net: annual_gross - annual_tax
rule min_annual_gross: labor.min_wage_hourly * labor.max_weekly_hours * 52
rule meets_minimum: annual_gross >= min_annual_gross
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"employment.lemma",
))),
)
.unwrap();
let data = vec![("hourly_rate", "20"), ("weekly_hours", "40")];
let r = eval_with(&engine, "employment", &date(2025, 2, 1), data.clone());
assert_rule_value(&r, "annual_gross", "41600");
assert_rule_value(&r, "annual_tax", "12480");
assert_rule_value(&r, "annual_net", "29120");
assert_rule_value(&r, "min_annual_gross", "24960");
assert_rule_value(&r, "meets_minimum", "true");
let r = eval_with(&engine, "employment", &date(2025, 5, 1), data.clone());
assert_rule_value(&r, "annual_tax", "13312");
assert_rule_value(&r, "annual_net", "28288");
assert_rule_value(&r, "min_annual_gross", "24960");
let r = eval_with(&engine, "employment", &date(2025, 9, 1), data.clone());
assert_rule_value(&r, "annual_tax", "13312");
assert_rule_value(&r, "min_annual_gross", "29640");
assert_rule_value(&r, "meets_minimum", "true");
}
#[test]
fn both_spec_and_dep_are_versioned() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep
data val: 10
spec dep 2025-06-01
data val: 20
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec main 2025-01-01
uses d: dep
data multiplier: 2
rule result: d.val * multiplier
spec main 2025-04-01
uses d: dep
data multiplier: 3
rule result: d.val * multiplier
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("main.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "main", &date(2025, 2, 1)), "result", "20");
assert_rule_value(&eval(&engine, "main", &date(2025, 5, 1)), "result", "30");
assert_rule_value(&eval(&engine, "main", &date(2025, 9, 1)), "result", "60");
}
#[test]
fn dep_version_removes_referenced_data_rejected() {
let mut engine = Engine::new();
engine
.load(
r#"
spec config
data base_rate: 100
spec config 2025-04-01
data cost: 200
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"config.lemma",
))),
)
.unwrap();
let result = engine.load(
r#"
spec pricing 2025-01-01
uses cfg: config
rule rate: cfg.base_rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"pricing.lemma",
))),
);
assert!(
result.is_err(),
"Must reject: config v2 (April+) removed 'base_rate' that pricing references"
);
let errs = result.unwrap_err();
assert!(
errs.iter().any(|e| e.to_string().contains("base_rate")),
"Error should mention missing 'base_rate'. Got: {}",
errs.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
#[test]
fn dep_version_adds_rule_that_other_spec_doesnt_use_accepted() {
let mut engine = Engine::new();
engine
.load(
r#"
spec policy
data base: 100
rule discount: 10
spec policy 2025-06-01
data base: 200
rule discount: 20
rule bonus: 5
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"policy.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec contract 2025-01-01
uses p: policy
rule applied_discount: p.discount
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"contract.lemma",
))),
)
.unwrap();
assert_rule_value(
&eval(&engine, "contract", &date(2025, 3, 1)),
"applied_discount",
"10",
);
assert_rule_value(
&eval(&engine, "contract", &date(2025, 9, 1)),
"applied_discount",
"20",
);
}
#[test]
fn slice_compat_dep_adds_unreferenced_data() {
let mut engine = Engine::new();
engine
.load(
r#"
spec settings
data limit: 10
spec settings 2025-05-01
data limit: 20
data description: "updated settings"
data extra_number: 999
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"settings.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec app 2025-01-01
uses s: settings
rule max: s.limit
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("app.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "app", &date(2025, 3, 1)), "max", "10");
assert_rule_value(&eval(&engine, "app", &date(2025, 8, 1)), "max", "20");
}
#[test]
fn slice_compat_dep_adds_unreferenced_rules() {
let mut engine = Engine::new();
engine
.load(
r#"
spec calc
rule base_fee: 100
spec calc 2025-04-01
rule base_fee: 150
rule surcharge: 25
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("calc.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec invoice 2025-01-01
uses c: calc
rule fee: c.base_fee
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"invoice.lemma",
))),
)
.unwrap();
assert_rule_value(&eval(&engine, "invoice", &date(2025, 2, 1)), "fee", "100");
assert_rule_value(&eval(&engine, "invoice", &date(2025, 6, 1)), "fee", "150");
}
#[test]
fn dependent_versions_track_dep_interface_change() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep
data x: 10
spec dep 2025-06-01
data x: "hello"
spec consumer 2025-01-01
uses d: dep
rule val: d.x
spec consumer 2025-06-01
uses d: dep
rule val: d.x
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "consumer", &date(2025, 3, 1)), "val", "10");
assert_rule_value(
&eval(&engine, "consumer", &date(2025, 9, 1)),
"val",
"hello",
);
}
#[test]
fn app_temporal_versions_distinct_rules_per_dep_interface_era() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep
data x: 10
spec dep 2025-06-01
data x: "hello"
spec app 2025-01-01
uses d: dep
rule total: d.x + 2
spec app 2025-06-01
uses d: dep
rule greeting: d.x
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"app_dep.lemma",
))),
)
.unwrap();
assert_rule_value(&eval(&engine, "app", &date(2025, 3, 1)), "total", "12");
assert_rule_value(
&eval(&engine, "app", &date(2025, 9, 1)),
"greeting",
"hello",
);
}
#[test]
fn slice_compat_dep_rule_value_changes_but_type_same() {
let mut engine = Engine::new();
engine
.load(
r#"
spec policy
rule discount: 10
spec policy 2025-05-01
rule discount: 25
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"policy.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec shop 2025-01-01
uses p: policy
rule d: p.discount
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("shop.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "shop", &date(2025, 2, 1)), "d", "10");
assert_rule_value(&eval(&engine, "shop", &date(2025, 8, 1)), "d", "25");
}
#[test]
fn slice_compat_dep_data_type_annotation_identical() {
let mut engine = Engine::new();
engine
.load(
r#"
spec cfg
data threshold: number
spec cfg 2025-04-01
data threshold: number
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("cfg.lemma"))),
)
.unwrap();
engine
.load(
r#"
spec consumer 2025-01-01
uses c: cfg
data c.threshold: 50
rule t: c.threshold
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"consumer.lemma",
))),
)
.unwrap();
assert_rule_value(&eval(&engine, "consumer", &date(2025, 2, 1)), "t", "50");
assert_rule_value(&eval(&engine, "consumer", &date(2025, 6, 1)), "t", "50");
}
#[test]
fn slice_incompat_multiple_deps_one_unstable() {
let mut engine = Engine::new();
engine
.load(
r#"
spec stable_dep
data x: 10
spec stable_dep 2025-06-01
data x: 20
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"stable.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec unstable_dep
data y: 5
spec unstable_dep 2025-06-01
data y: "five"
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"unstable.lemma",
))),
)
.unwrap();
let result = engine.load(
r#"
spec consumer 2025-01-01
uses a: stable_dep
uses b: unstable_dep
rule sx: a.x
rule sy: b.y
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"consumer.lemma",
))),
);
assert!(
result.is_err(),
"Must reject: unstable_dep.y changes from number to text"
);
let errs = result.unwrap_err();
let joined = errs
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" | ");
assert!(
joined.contains("changed its interface between temporal slices"),
"Error must come from SliceInterface validation. Got: {}",
joined
);
assert!(
joined.contains("unstable_dep"),
"Error must identify unstable_dep as the changed dependency. Got: {}",
joined
);
}
#[test]
fn slice_edge_data_referenced_only_in_unless_branch() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep
data main_val: 10
data alt_val: 20
spec dep 2025-06-01
data main_val: "ten"
data alt_val: "twenty"
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
let result = engine.load(
r#"
spec caller 2025-01-01
uses d: dep
data use_alt: boolean
rule result: d.main_val
unless use_alt then d.alt_val
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"caller.lemma",
))),
);
assert!(
result.is_err(),
"Must reject: dep data change from number to text across slices"
);
let errs = result.unwrap_err();
let joined = errs
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" | ");
assert!(
joined.contains("changed its interface between temporal slices"),
"Error must come from SliceInterface validation. Got: {}",
joined
);
}
#[test]
fn slice_edge_data_in_both_condition_and_expression() {
let mut engine = Engine::new();
engine
.load(
r#"
spec amounts
data amount: 100
spec amounts 2025-05-01
data amount: 200
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"amounts.lemma",
))),
)
.unwrap();
engine
.load(
r#"
spec calc 2025-01-01
uses d: amounts
rule result: 0
unless d.amount > 50 then d.amount * 2
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("calc.lemma"))),
)
.unwrap();
assert_rule_value(&eval(&engine, "calc", &date(2025, 3, 1)), "result", "200");
assert_rule_value(&eval(&engine, "calc", &date(2025, 8, 1)), "result", "400");
}
#[test]
fn slice_edge_dep_removes_referenced_rule_caught_by_per_slice() {
let mut engine = Engine::new();
engine
.load(
r#"
spec svc
rule compute: 42
spec svc 2025-06-01
rule other_compute: 99
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("svc.lemma"))),
)
.unwrap();
let result = engine.load(
r#"
spec caller 2025-01-01
uses s: svc
rule val: s.compute
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"caller.lemma",
))),
);
assert!(
result.is_err(),
"Must reject: svc v2 no longer has rule 'compute' that caller references"
);
let errs = result.unwrap_err();
let joined = errs
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" | ");
assert!(
joined.contains("compute"),
"Error should mention the missing rule 'compute'. Got: {}",
joined
);
}
#[test]
fn adversarial_consumer_range_end_exclusive_dep_starting_at_end_not_covering() {
let mut engine = Engine::new();
let err = engine
.load(
r#"
spec consumer 2025-01-01
uses d: dep
rule x: d.v
spec dep 2025-06-01
data v: 1
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("adv.lemma"))),
)
.expect_err("dep must not cover consumer range ending at 2025-06-01");
let joined = err
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(
joined.contains("consumer") && joined.contains("dep"),
"expected coverage error; got {joined}"
);
}
#[test]
fn adversarial_consumer_starts_before_dep_first_effective_errors() {
let mut engine = Engine::new();
let err = engine
.load(
r#"
spec consumer 2025-03-01
uses d: dep
rule x: d.v
spec dep 2025-08-01
data v: 1
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("adv2.lemma"))),
)
.expect_err("gap before dep exists");
let joined = err
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(joined.contains("dep"), "got {joined}");
}
#[test]
fn slice_incompat_data_field_type_changes_number_to_text() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep 2025-01-01
data rate: number
spec dep 2025-07-01
data rate: text
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
let err = engine
.load(
r#"
spec app 2025-01-01
uses d: dep
rule r: d.rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("app.lemma"))),
)
.expect_err("interface change");
let joined = err
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(
joined.contains("interface") || joined.contains("temporal") || joined.contains("dep"),
"got {joined}"
);
}
#[test]
fn slice_incompat_rule_result_type_changes() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep 2025-01-01
rule discount: 5
spec dep 2025-07-01
rule discount: true
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
let err = engine
.load(
r#"
spec app 2025-01-01
uses d: dep
rule out: d.discount
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("app.lemma"))),
)
.expect_err("rule type mismatch across slices");
let joined = err
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(
joined.contains("interface") || joined.contains("dep"),
"got {joined}"
);
}
#[test]
fn slice_incompat_named_type_adds_unit_across_slices() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep 2025-01-01
data money: scale
-> unit eur 1.0
spec dep 2025-07-01
data money: scale
-> unit eur 1.0
-> unit usd 1.1
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
let err = engine
.load(
r#"
spec app 2025-01-01
data m: money from dep
rule x: 1
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("app.lemma"))),
)
.expect_err("type shape change");
let joined = err
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(
joined.contains("interface") || joined.contains("dep") || joined.contains("money"),
"got {joined}"
);
}
#[test]
fn slice_incompat_three_versions_middle_breaks_adjacent_pair() {
let mut engine = Engine::new();
engine
.load(
r#"
spec dep 2025-01-01
data rate: number
spec dep 2025-04-01
data rate: text
spec dep 2025-10-01
data rate: number
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.unwrap();
let err = engine
.load(
r#"
spec app 2025-01-01
uses d: dep
rule r: d.rate
"#,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("app.lemma"))),
)
.expect_err("middle slice incompatible");
let joined = err
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(!joined.is_empty(), "expected errors");
}