lemma-engine 0.8.21

A pure, declarative language for business rules.
Documentation
//! Ratio typedef constraints (`minimum`, `maximum`, `default`) with custom units.
//!
//! Parser emits `N label` as `Value::NumberWithUnit`; planning must resolve against
//! the typedef's `RatioUnits` (same as measure constraints after scale rename).

use lemma::DateTimeValue;
use lemma::Engine;
use lemma::TypeSpecification;
use lemma::ValueKind;
use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;

fn decimal_lit(s: &str) -> Decimal {
    Decimal::from_str(s).unwrap()
}

fn load(engine: &mut Engine, code: &str) {
    engine
        .load(
            code,
            lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
                "ratio_constraints.lemma",
            ))),
        )
        .unwrap_or_else(|errs| {
            let joined = errs
                .errors
                .iter()
                .map(|e| e.to_string())
                .collect::<Vec<_>>()
                .join("\n");
            panic!("expected load to succeed, got: {joined}");
        });
}

fn bps_spec() -> &'static str {
    r#"
spec s
data r: ratio
  -> unit basis_points 10000
  -> minimum 500 basis_points
  -> maximum 10000 basis_points
rule out: r
"#
}

#[test]
fn ratio_minimum_custom_unit_loads_with_canonical_bounds() {
    let mut engine = Engine::new();
    load(&mut engine, bps_spec());

    let now = DateTimeValue::now();
    let schema = engine.schema(None, "s", Some(&now)).expect("schema");
    let entry = schema.data.get("r").expect("data r");
    match &entry.lemma_type.specifications {
        TypeSpecification::Ratio { units, .. } => {
            assert_eq!(
                entry.lemma_type.specifications.minimum_decimal(),
                Some(decimal_lit("0.05")),
                "500 basis_points / 10000"
            );
            assert_eq!(
                entry.lemma_type.specifications.maximum_decimal(),
                Some(decimal_lit("1")),
                "10000 basis_points / 10000"
            );
            let bps = units.get("basis_points").expect("basis_points unit");
            assert_eq!(bps.minimum_decimal(), Some(decimal_lit("500")));
            assert_eq!(bps.maximum_decimal(), Some(decimal_lit("10000")));
        }
        other => panic!("expected Ratio type, got {:?}", other),
    }
}

#[test]
fn ratio_bare_minimum_rejected_at_load() {
    let code = r#"
spec s
data r: ratio -> minimum 0
rule out: r
"#;
    let mut engine = Engine::new();
    let err = engine
        .load(
            code,
            lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
                "ratio_constraints.lemma",
            ))),
        )
        .expect_err("bare number minimum on ratio must fail");
    let s = err
        .errors
        .iter()
        .map(|e| e.to_string())
        .collect::<Vec<_>>()
        .join("\n")
        .to_lowercase();
    assert!(
        s.contains("ratio") && (s.contains("unit") || s.contains("%") || s.contains("bare")),
        "expected ratio unit syntax error, got: {s}"
    );
}

#[test]
fn ratio_bare_default_rejected_at_load() {
    let code = r#"
spec s
data r: ratio -> default 0.015
rule out: r
"#;
    let mut engine = Engine::new();
    let err = engine
        .load(
            code,
            lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
                "ratio_constraints.lemma",
            ))),
        )
        .expect_err("bare number default on ratio must fail");
    let s = err
        .errors
        .iter()
        .map(|e| e.to_string())
        .collect::<Vec<_>>()
        .join("\n")
        .to_lowercase();
    assert!(
        s.contains("default") && (s.contains("unit") || s.contains("%")),
        "expected ratio default unit syntax error, got: {s}"
    );
}

#[test]
fn ratio_bare_maximum_rejected_at_load() {
    let code = r#"
spec s
data r: ratio -> maximum 1
rule out: r
"#;
    let mut engine = Engine::new();
    let err = engine
        .load(
            code,
            lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
                "ratio_constraints.lemma",
            ))),
        )
        .expect_err("bare number maximum on ratio must fail");
    let s = err
        .errors
        .iter()
        .map(|e| e.to_string())
        .collect::<Vec<_>>()
        .join("\n")
        .to_lowercase();
    assert!(
        s.contains("maximum") && (s.contains("unit") || s.contains("%")),
        "expected ratio maximum unit syntax error, got: {s}"
    );
}

#[test]
fn ratio_default_percent_loads() {
    let code = r#"
spec s
data r: ratio -> default 1.5%
rule out: r
"#;
    let mut engine = Engine::new();
    load(&mut engine, code);

    let now = DateTimeValue::now();
    let schema = engine.schema(None, "s", Some(&now)).expect("schema");
    let entry = schema.data.get("r").expect("data r");
    let default = entry.default.as_ref().expect("declared default");
    match &default.value {
        ValueKind::Ratio(n, u) => {
            assert_eq!(
                lemma::ValueKind::Number(n.clone())
                    .as_decimal_magnitude()
                    .unwrap(),
                decimal_lit("0.015")
            );
            assert_eq!(u.as_deref(), Some("percent"));
        }
        other => panic!("expected Ratio default, got {:?}", other),
    }
}

#[test]
fn ratio_minimum_percent_constraint_loads() {
    let code = r#"
spec s
data r: ratio -> minimum 10% -> maximum 100%
rule out: r
"#;
    let mut engine = Engine::new();
    load(&mut engine, code);

    let now = DateTimeValue::now();
    let schema = engine.schema(None, "s", Some(&now)).expect("schema");
    let entry = schema.data.get("r").expect("data r");
    assert_eq!(
        entry.lemma_type.specifications.minimum_decimal(),
        Some(decimal_lit("0.10"))
    );
}

#[test]
fn ratio_minimum_custom_unit_override_enforced() {
    let mut engine = Engine::new();
    load(&mut engine, bps_spec());

    let mut data = HashMap::new();
    data.insert("r".to_string(), "400 basis_points".to_string());

    let now = DateTimeValue::now();
    let resp = engine
        .run(None, "s", Some(&now), data, true, None)
        .expect("400 bps < 500 bps minimum must complete with veto");
    let rr = resp.results.get("out").expect("rule out");
    assert!(rr.vetoed, "400 bps < 500 bps minimum must veto");
    let message = rr.veto_reason.clone().expect("veto reason");
    assert!(
        message.contains("minimum") || message.to_lowercase().contains("below"),
        "expected minimum violation, got: {message}"
    );
    assert!(
        message.contains("500 basis_points"),
        "expected per-unit bound in message, got: {message}"
    );
    assert!(
        !message.contains("0.05"),
        "must not show canonical ratio magnitude, got: {message}"
    );
    assert!(
        !message.to_lowercase().contains("canonical"),
        "must not mention canonical units, got: {message}"
    );
}

#[test]
fn ratio_default_custom_unit_loads() {
    let code = r#"
spec s
data r: ratio
  -> unit basis_points 10000
  -> default 500 basis_points
rule out: r
"#;
    let mut engine = Engine::new();
    load(&mut engine, code);

    let now = DateTimeValue::now();
    let schema = engine.schema(None, "s", Some(&now)).expect("schema");
    let entry = schema.data.get("r").expect("data r");
    let default = entry.default.as_ref().expect("declared default");
    match &default.value {
        ValueKind::Ratio(n, u) => {
            assert_eq!(
                lemma::ValueKind::Number(n.clone())
                    .as_decimal_magnitude()
                    .unwrap(),
                decimal_lit("0.05")
            );
            assert_eq!(u.as_deref(), Some("basis_points"));
        }
        other => panic!("expected Ratio default, got {:?}", other),
    }
}

#[test]
fn ratio_minimum_custom_unit_override_accepts_at_bound() {
    let mut engine = Engine::new();
    load(&mut engine, bps_spec());

    let mut data = HashMap::new();
    data.insert("r".to_string(), "500 basis_points".to_string());

    let now = DateTimeValue::now();
    let resp = engine
        .run(None, "s", Some(&now), data, true, None)
        .expect("500 bps meets minimum");
    let rr = resp.results.get("out").expect("rule out");
    if rr.vetoed {
        panic!(
            "unexpected veto: {}",
            rr.veto_reason.as_deref().unwrap_or("Vetoed")
        );
    }
    let lit = rr
        .explanation
        .as_ref()
        .expect("explanation")
        .result
        .value()
        .expect("value");
    match &lit.value {
        ValueKind::Ratio(n, u) => {
            assert_eq!(
                lemma::ValueKind::Number(n.clone())
                    .as_decimal_magnitude()
                    .unwrap(),
                decimal_lit("0.05")
            );
            assert_eq!(u.as_deref(), Some("basis_points"));
        }
        other => panic!("expected Ratio, got {:?}", other),
    }
}