use crate::parsing::ast::{DateTimeValue, LemmaSpec, MetaValue};
use crate::planning::graph::Graph;
use crate::planning::semantics;
use crate::planning::semantics::{
Expression, FactData, FactPath, LemmaType, LiteralValue, RulePath, TypeSpecification, ValueKind,
};
use crate::planning::types::ResolvedSpecTypes;
use crate::Error;
use crate::ResourceLimits;
use crate::Source;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpecId {
pub name: String,
pub plan_hash: String,
}
impl SpecId {
#[must_use]
pub fn new(name: String, plan_hash: String) -> Self {
Self { name, plan_hash }
}
}
impl std::fmt::Display for SpecId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}~{}", self.name, self.plan_hash)
}
}
pub type SpecSources = IndexMap<(String, Option<DateTimeValue>), String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionPlan {
pub spec_name: String,
#[serde(serialize_with = "crate::serialization::serialize_resolved_fact_value_map")]
#[serde(deserialize_with = "crate::serialization::deserialize_resolved_fact_value_map")]
pub facts: IndexMap<FactPath, FactData>,
pub rules: Vec<ExecutableRule>,
pub meta: HashMap<String, MetaValue>,
pub named_types: BTreeMap<String, LemmaType>,
pub valid_from: Option<DateTimeValue>,
pub valid_to: Option<DateTimeValue>,
#[serde(default)]
#[serde(
serialize_with = "serialize_sources",
deserialize_with = "deserialize_sources"
)]
pub sources: SpecSources,
}
impl ExecutionPlan {
#[must_use]
pub fn plan_hash(&self) -> String {
crate::planning::fingerprint::fingerprint_hash(&crate::planning::fingerprint::from_plan(
self,
))
}
}
fn serialize_sources<S>(sources: &SpecSources, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(sources.len()))?;
for ((name, effective_from), source) in sources {
seq.serialize_element(&SpecSourceEntry {
name,
effective_from,
source,
})?;
}
seq.end()
}
fn deserialize_sources<'de, D>(deserializer: D) -> Result<SpecSources, D::Error>
where
D: serde::Deserializer<'de>,
{
let entries: Vec<SpecSourceEntryOwned> = Vec::deserialize(deserializer)?;
let mut map = IndexMap::with_capacity(entries.len());
for e in entries {
map.insert((e.name, e.effective_from), e.source);
}
Ok(map)
}
#[derive(Serialize)]
struct SpecSourceEntry<'a> {
name: &'a str,
effective_from: &'a Option<DateTimeValue>,
source: &'a str,
}
#[derive(Deserialize)]
struct SpecSourceEntryOwned {
name: String,
effective_from: Option<DateTimeValue>,
source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutableRule {
pub path: RulePath,
pub name: String,
pub branches: Vec<Branch>,
pub needs_facts: BTreeSet<FactPath>,
pub source: Source,
pub rule_type: LemmaType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
pub condition: Option<Expression>,
pub result: Expression,
pub source: Source,
}
pub(crate) fn build_execution_plan(
graph: &Graph,
resolved_types: &HashMap<Arc<LemmaSpec>, ResolvedSpecTypes>,
valid_from: Option<DateTimeValue>,
valid_to: Option<DateTimeValue>,
) -> ExecutionPlan {
let facts = graph.build_facts();
let execution_order = graph.execution_order();
let mut executable_rules: Vec<ExecutableRule> = Vec::new();
let mut path_to_index: HashMap<RulePath, usize> = HashMap::new();
for rule_path in execution_order {
let rule_node = graph.rules().get(rule_path).expect(
"bug: rule from topological sort not in graph - validation should have caught this",
);
let mut direct_facts = HashSet::new();
for (condition, result) in &rule_node.branches {
if let Some(cond) = condition {
cond.collect_fact_paths(&mut direct_facts);
}
result.collect_fact_paths(&mut direct_facts);
}
let mut needs_facts: BTreeSet<FactPath> = direct_facts.into_iter().collect();
for dep in &rule_node.depends_on_rules {
if let Some(&dep_idx) = path_to_index.get(dep) {
needs_facts.extend(executable_rules[dep_idx].needs_facts.iter().cloned());
}
}
let mut executable_branches = Vec::new();
for (condition, result) in &rule_node.branches {
executable_branches.push(Branch {
condition: condition.clone(),
result: result.clone(),
source: rule_node.source.clone(),
});
}
path_to_index.insert(rule_path.clone(), executable_rules.len());
executable_rules.push(ExecutableRule {
path: rule_path.clone(),
name: rule_path.rule.clone(),
branches: executable_branches,
source: rule_node.source.clone(),
needs_facts,
rule_type: rule_node.rule_type.clone(),
});
}
let main_spec = graph.main_spec();
let named_types = build_type_tables(main_spec, resolved_types);
let mut sources: SpecSources = IndexMap::new();
for spec in resolved_types.keys() {
let key = (spec.name.clone(), spec.effective_from.clone());
sources
.entry(key)
.or_insert_with(|| crate::formatting::format_spec(spec, crate::formatting::MAX_COLS));
}
ExecutionPlan {
spec_name: main_spec.name.clone(),
facts,
rules: executable_rules,
meta: main_spec
.meta_fields
.iter()
.map(|f| (f.key.clone(), f.value.clone()))
.collect(),
named_types,
valid_from,
valid_to,
sources,
}
}
fn build_type_tables(
main_spec: &Arc<LemmaSpec>,
resolved_types: &HashMap<Arc<LemmaSpec>, ResolvedSpecTypes>,
) -> BTreeMap<String, LemmaType> {
let mut named_types = BTreeMap::new();
let main_resolved = resolved_types
.iter()
.find(|(spec, _)| Arc::ptr_eq(spec, main_spec))
.map(|(_, types)| types);
if let Some(resolved) = main_resolved {
for (type_name, lemma_type) in &resolved.named_types {
named_types.insert(type_name.clone(), lemma_type.clone());
}
}
named_types
}
#[derive(Debug, Clone, Serialize)]
pub struct SpecSchema {
pub spec: String,
pub facts: indexmap::IndexMap<String, (LemmaType, Option<LiteralValue>)>,
pub rules: indexmap::IndexMap<String, LemmaType>,
pub meta: HashMap<String, MetaValue>,
}
impl std::fmt::Display for SpecSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Spec: {}", self.spec)?;
if !self.meta.is_empty() {
write!(f, "\n\nMeta:")?;
let mut keys: Vec<&String> = self.meta.keys().collect();
keys.sort();
for key in keys {
write!(f, "\n {}: {}", key, self.meta.get(key).unwrap())?;
}
}
if !self.facts.is_empty() {
write!(f, "\n\nFacts:")?;
for (name, (lemma_type, default)) in &self.facts {
write!(f, "\n {} ({}", name, lemma_type.name())?;
if let Some(constraints) = format_type_constraints(&lemma_type.specifications) {
write!(f, ", {}", constraints)?;
}
if let Some(val) = default {
write!(f, ", default: {}", val)?;
}
write!(f, ")")?;
}
}
if !self.rules.is_empty() {
write!(f, "\n\nRules:")?;
for (name, rule_type) in &self.rules {
write!(f, "\n {} ({})", name, rule_type.name())?;
}
}
if self.facts.is_empty() && self.rules.is_empty() {
write!(f, "\n (no facts or rules)")?;
}
Ok(())
}
}
fn format_type_constraints(spec: &TypeSpecification) -> Option<String> {
let mut parts = Vec::new();
match spec {
TypeSpecification::Number {
minimum, maximum, ..
} => {
if let Some(v) = minimum {
parts.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
parts.push(format!("maximum: {}", v));
}
}
TypeSpecification::Scale {
minimum,
maximum,
decimals,
units,
..
} => {
let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
if !unit_names.is_empty() {
parts.push(format!("units: {}", unit_names.join(", ")));
}
if let Some(v) = minimum {
parts.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
parts.push(format!("maximum: {}", v));
}
if let Some(d) = decimals {
parts.push(format!("decimals: {}", d));
}
}
TypeSpecification::Ratio {
minimum, maximum, ..
} => {
if let Some(v) = minimum {
parts.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
parts.push(format!("maximum: {}", v));
}
}
TypeSpecification::Text { options, .. } => {
if !options.is_empty() {
let quoted: Vec<String> = options.iter().map(|o| format!("\"{}\"", o)).collect();
parts.push(format!("options: {}", quoted.join(", ")));
}
}
TypeSpecification::Date {
minimum, maximum, ..
} => {
if let Some(v) = minimum {
parts.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
parts.push(format!("maximum: {}", v));
}
}
TypeSpecification::Time {
minimum, maximum, ..
} => {
if let Some(v) = minimum {
parts.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
parts.push(format!("maximum: {}", v));
}
}
TypeSpecification::Boolean { .. }
| TypeSpecification::Duration { .. }
| TypeSpecification::Veto { .. }
| TypeSpecification::Undetermined => {}
}
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}
impl ExecutionPlan {
pub fn schema(&self) -> SpecSchema {
let mut fact_entries: Vec<(usize, String, (LemmaType, Option<LiteralValue>))> = self
.facts
.iter()
.filter(|(_, data)| data.schema_type().is_some())
.map(|(path, data)| {
let lemma_type = data.schema_type().unwrap().clone();
let value = data.explicit_value().cloned();
(
data.source().span.start,
path.input_key(),
(lemma_type, value),
)
})
.collect();
fact_entries.sort_by_key(|(pos, _, _)| *pos);
let fact_entries: Vec<(String, (LemmaType, Option<LiteralValue>))> = fact_entries
.into_iter()
.map(|(_, name, data)| (name, data))
.collect();
let rule_entries: Vec<(String, LemmaType)> = self
.rules
.iter()
.filter(|r| r.path.segments.is_empty())
.map(|r| (r.name.clone(), r.rule_type.clone()))
.collect();
SpecSchema {
spec: self.spec_name.clone(),
facts: fact_entries.into_iter().collect(),
rules: rule_entries.into_iter().collect(),
meta: self.meta.clone(),
}
}
pub fn schema_for_rules(&self, rule_names: &[String]) -> Result<SpecSchema, Error> {
let mut needed_facts = HashSet::new();
let mut rule_entries: Vec<(String, LemmaType)> = Vec::new();
for rule_name in rule_names {
let rule = self.get_rule(rule_name).ok_or_else(|| {
Error::request(
format!(
"Rule '{}' not found in spec '{}'",
rule_name, self.spec_name
),
None::<String>,
)
})?;
needed_facts.extend(rule.needs_facts.iter().cloned());
rule_entries.push((rule.name.clone(), rule.rule_type.clone()));
}
let mut fact_entries: Vec<(usize, String, (LemmaType, Option<LiteralValue>))> = self
.facts
.iter()
.filter(|(path, _)| needed_facts.contains(path))
.filter(|(_, data)| data.schema_type().is_some())
.map(|(path, data)| {
let lemma_type = data.schema_type().unwrap().clone();
let value = data.explicit_value().cloned();
(
data.source().span.start,
path.input_key(),
(lemma_type, value),
)
})
.collect();
fact_entries.sort_by_key(|(pos, _, _)| *pos);
let fact_entries: Vec<(String, (LemmaType, Option<LiteralValue>))> = fact_entries
.into_iter()
.map(|(_, name, data)| (name, data))
.collect();
Ok(SpecSchema {
spec: self.spec_name.clone(),
facts: fact_entries.into_iter().collect(),
rules: rule_entries.into_iter().collect(),
meta: self.meta.clone(),
})
}
pub fn get_fact_path_by_str(&self, name: &str) -> Option<&FactPath> {
self.facts.keys().find(|path| path.input_key() == name)
}
pub fn get_rule(&self, name: &str) -> Option<&ExecutableRule> {
self.rules
.iter()
.find(|r| r.name == name && r.path.segments.is_empty())
}
pub fn get_rule_by_path(&self, rule_path: &RulePath) -> Option<&ExecutableRule> {
self.rules.iter().find(|r| &r.path == rule_path)
}
pub fn get_fact_value(&self, path: &FactPath) -> Option<&LiteralValue> {
self.facts.get(path).and_then(|d| d.value())
}
pub fn with_fact_values(
mut self,
values: HashMap<String, String>,
limits: &ResourceLimits,
) -> Result<Self, Error> {
for (name, raw_value) in values {
let fact_path = self.get_fact_path_by_str(&name).ok_or_else(|| {
let available: Vec<String> = self.facts.keys().map(|p| p.input_key()).collect();
Error::request(
format!(
"Fact '{}' not found. Available facts: {}",
name,
available.join(", ")
),
None::<String>,
)
})?;
let fact_path = fact_path.clone();
let fact_data = self
.facts
.get(&fact_path)
.expect("BUG: fact_path was just resolved from self.facts, must exist");
let fact_source = fact_data.source().clone();
let expected_type = fact_data.schema_type().cloned().ok_or_else(|| {
Error::request(
format!(
"Fact '{}' is a spec reference; cannot provide a value.",
name
),
None::<String>,
)
})?;
let parsed_value = crate::planning::semantics::parse_value_from_string(
&raw_value,
&expected_type.specifications,
&fact_source,
)
.map_err(|e| {
Error::validation(
format!(
"Failed to parse fact '{}' as {}: {}",
name,
expected_type.name(),
e
),
Some(fact_source.clone()),
None::<String>,
)
})?;
let semantic_value = semantics::value_to_semantic(&parsed_value).map_err(|e| {
Error::validation(
format!("Failed to convert fact '{}' value: {}", name, e),
Some(fact_source.clone()),
None::<String>,
)
})?;
let literal_value = LiteralValue {
value: semantic_value,
lemma_type: expected_type.clone(),
};
let size = literal_value.byte_size();
if size > limits.max_fact_value_bytes {
return Err(Error::resource_limit_exceeded(
"max_fact_value_bytes",
limits.max_fact_value_bytes.to_string(),
size.to_string(),
format!(
"Reduce the size of fact values to {} bytes or less",
limits.max_fact_value_bytes
),
Some(fact_source.clone()),
None,
None,
));
}
validate_value_against_type(&expected_type, &literal_value).map_err(|msg| {
Error::validation(
format!(
"Invalid value for fact {} (expected {}): {}",
name,
expected_type.name(),
msg
),
Some(fact_source.clone()),
None::<String>,
)
})?;
self.facts.insert(
fact_path,
FactData::Value {
value: literal_value,
source: fact_source,
is_default: false,
},
);
}
Ok(self)
}
}
fn validate_value_against_type(
expected_type: &LemmaType,
value: &LiteralValue,
) -> Result<(), String> {
use crate::planning::semantics::TypeSpecification;
let effective_decimals = |n: rust_decimal::Decimal| n.scale();
match (&expected_type.specifications, &value.value) {
(
TypeSpecification::Number {
minimum,
maximum,
decimals,
..
},
ValueKind::Number(n),
) => {
if let Some(min) = minimum {
if n < min {
return Err(format!("{} is below minimum {}", n, min));
}
}
if let Some(max) = maximum {
if n > max {
return Err(format!("{} is above maximum {}", n, max));
}
}
if let Some(d) = decimals {
if effective_decimals(*n) > u32::from(*d) {
return Err(format!("{} has more than {} decimals", n, d));
}
}
Ok(())
}
(
TypeSpecification::Scale {
minimum,
maximum,
decimals,
..
},
ValueKind::Scale(n, _unit),
) => {
if let Some(min) = minimum {
if n < min {
return Err(format!("{} is below minimum {}", n, min));
}
}
if let Some(max) = maximum {
if n > max {
return Err(format!("{} is above maximum {}", n, max));
}
}
if let Some(d) = decimals {
if effective_decimals(*n) > u32::from(*d) {
return Err(format!("{} has more than {} decimals", n, d));
}
}
Ok(())
}
(TypeSpecification::Text { options, .. }, ValueKind::Text(s)) => {
if !options.is_empty() && !options.iter().any(|opt| opt == s) {
return Err(format!(
"'{}' is not in allowed options: {}",
s,
options.join(", ")
));
}
Ok(())
}
_ => Ok(()),
}
}
pub(crate) fn validate_literal_facts_against_types(plan: &ExecutionPlan) -> Vec<Error> {
let mut errors = Vec::new();
for (fact_path, fact_data) in &plan.facts {
let (expected_type, lit) = match fact_data {
FactData::Value { value, .. } => (&value.lemma_type, value),
FactData::TypeDeclaration { .. } | FactData::SpecRef { .. } => continue,
};
if let Err(msg) = validate_value_against_type(expected_type, lit) {
let source = fact_data.source().clone();
errors.push(Error::validation(
format!(
"Invalid value for fact {} (expected {}): {}",
fact_path,
expected_type.name(),
msg
),
Some(source),
None::<String>,
));
}
}
errors
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parsing::ast::DateTimeValue;
use crate::planning::semantics::{
primitive_boolean, primitive_text, FactPath, LiteralValue, PathSegment, RulePath,
};
use crate::Engine;
use serde_json;
use std::str::FromStr;
use std::sync::Arc;
fn default_limits() -> ResourceLimits {
ResourceLimits::default()
}
#[test]
fn test_with_raw_values() {
let mut engine = Engine::new();
engine
.load(
r#"
spec test
fact age: [number -> default 25]
"#,
crate::SourceType::Labeled("test.lemma"),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan("test", Some(&now)).unwrap().clone();
let fact_path = FactPath::new(vec![], "age".to_string());
let mut values = HashMap::new();
values.insert("age".to_string(), "30".to_string());
let updated_plan = plan.with_fact_values(values, &default_limits()).unwrap();
let updated_value = updated_plan.get_fact_value(&fact_path).unwrap();
match &updated_value.value {
crate::planning::semantics::ValueKind::Number(n) => {
assert_eq!(n, &rust_decimal::Decimal::from(30))
}
other => panic!("Expected number literal, got {:?}", other),
}
}
#[test]
fn test_with_raw_values_type_mismatch() {
let mut engine = Engine::new();
engine
.load(
r#"
spec test
fact age: [number]
"#,
crate::SourceType::Labeled("test.lemma"),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan("test", Some(&now)).unwrap().clone();
let mut values = HashMap::new();
values.insert("age".to_string(), "thirty".to_string());
assert!(plan.with_fact_values(values, &default_limits()).is_err());
}
#[test]
fn test_with_raw_values_unknown_fact() {
let mut engine = Engine::new();
engine
.load(
r#"
spec test
fact known: [number]
"#,
crate::SourceType::Labeled("test.lemma"),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan("test", Some(&now)).unwrap().clone();
let mut values = HashMap::new();
values.insert("unknown".to_string(), "30".to_string());
assert!(plan.with_fact_values(values, &default_limits()).is_err());
}
#[test]
fn test_with_raw_values_nested() {
let mut engine = Engine::new();
engine
.load(
r#"
spec private
fact base_price: [number]
spec test
fact rules: spec private
"#,
crate::SourceType::Labeled("test.lemma"),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan("test", Some(&now)).unwrap().clone();
let mut values = HashMap::new();
values.insert("rules.base_price".to_string(), "100".to_string());
let updated_plan = plan.with_fact_values(values, &default_limits()).unwrap();
let fact_path = FactPath {
segments: vec![PathSegment {
fact: "rules".to_string(),
spec: "private".to_string(),
}],
fact: "base_price".to_string(),
};
let updated_value = updated_plan.get_fact_value(&fact_path).unwrap();
match &updated_value.value {
crate::planning::semantics::ValueKind::Number(n) => {
assert_eq!(n, &rust_decimal::Decimal::from(100))
}
other => panic!("Expected number literal, got {:?}", other),
}
}
fn test_source() -> crate::Source {
use crate::parsing::ast::Span;
crate::Source::new(
"<test>",
Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
)
}
fn create_literal_expr(value: LiteralValue) -> Expression {
Expression::new(
crate::planning::semantics::ExpressionKind::Literal(Box::new(value)),
test_source(),
)
}
fn create_fact_path_expr(path: FactPath) -> Expression {
Expression::new(
crate::planning::semantics::ExpressionKind::FactPath(path),
test_source(),
)
}
fn create_number_literal(n: rust_decimal::Decimal) -> LiteralValue {
LiteralValue::number(n)
}
fn create_boolean_literal(b: bool) -> LiteralValue {
LiteralValue::from_bool(b)
}
fn create_text_literal(s: String) -> LiteralValue {
LiteralValue::text(s)
}
#[test]
fn with_values_should_enforce_number_maximum_constraint() {
let fact_path = FactPath::new(vec![], "x".to_string());
let max10 = crate::planning::semantics::LemmaType::primitive(
crate::planning::semantics::TypeSpecification::Number {
minimum: None,
maximum: Some(rust_decimal::Decimal::from_str("10").unwrap()),
decimals: None,
precision: None,
help: String::new(),
default: None,
},
);
let source = Source::new(
"<test>",
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
);
let mut facts = IndexMap::new();
facts.insert(
fact_path.clone(),
crate::planning::semantics::FactData::Value {
value: crate::planning::semantics::LiteralValue::number_with_type(
0.into(),
max10.clone(),
),
source: source.clone(),
is_default: false,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let mut values = HashMap::new();
values.insert("x".to_string(), "11".to_string());
assert!(
plan.with_fact_values(values, &default_limits()).is_err(),
"Providing x=11 should fail due to maximum 10"
);
}
#[test]
fn with_values_should_enforce_text_enum_options() {
let fact_path = FactPath::new(vec![], "tier".to_string());
let tier = crate::planning::semantics::LemmaType::primitive(
crate::planning::semantics::TypeSpecification::Text {
minimum: None,
maximum: None,
length: None,
options: vec!["silver".to_string(), "gold".to_string()],
help: String::new(),
default: None,
},
);
let source = Source::new(
"<test>",
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
);
let mut facts = IndexMap::new();
facts.insert(
fact_path.clone(),
crate::planning::semantics::FactData::Value {
value: crate::planning::semantics::LiteralValue::text_with_type(
"silver".to_string(),
tier.clone(),
),
source,
is_default: false,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let mut values = HashMap::new();
values.insert("tier".to_string(), "platinum".to_string());
assert!(
plan.with_fact_values(values, &default_limits()).is_err(),
"Invalid enum value should be rejected (tier='platinum')"
);
}
#[test]
fn with_values_should_enforce_scale_decimals() {
let fact_path = FactPath::new(vec![], "price".to_string());
let money = crate::planning::semantics::LemmaType::primitive(
crate::planning::semantics::TypeSpecification::Scale {
minimum: None,
maximum: None,
decimals: Some(2),
precision: None,
units: crate::planning::semantics::ScaleUnits::from(vec![
crate::planning::semantics::ScaleUnit {
name: "eur".to_string(),
value: rust_decimal::Decimal::from_str("1.0").unwrap(),
},
]),
help: String::new(),
default: None,
},
);
let source = Source::new(
"<test>",
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
);
let mut facts = IndexMap::new();
facts.insert(
fact_path.clone(),
crate::planning::semantics::FactData::Value {
value: crate::planning::semantics::LiteralValue::scale_with_type(
rust_decimal::Decimal::from_str("0").unwrap(),
"eur".to_string(),
money.clone(),
),
source,
is_default: false,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let mut values = HashMap::new();
values.insert("price".to_string(), "1.234 eur".to_string());
assert!(
plan.with_fact_values(values, &default_limits()).is_err(),
"Scale decimals=2 should reject 1.234 eur"
);
}
#[test]
fn test_serialize_deserialize_execution_plan() {
let fact_path = FactPath {
segments: vec![],
fact: "age".to_string(),
};
let mut facts = IndexMap::new();
facts.insert(
fact_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(0.into()),
source: test_source(),
is_default: false,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.spec_name, plan.spec_name);
assert_eq!(deserialized.facts.len(), plan.facts.len());
assert_eq!(deserialized.rules.len(), plan.rules.len());
}
#[test]
fn test_serialize_deserialize_plan_with_imported_named_type_defining_spec() {
let dep_spec = Arc::new(crate::parsing::ast::LemmaSpec::new("examples".to_string()));
let imported_type = crate::planning::semantics::LemmaType::new(
"salary".to_string(),
TypeSpecification::scale(),
crate::planning::semantics::TypeExtends::Custom {
parent: "money".to_string(),
family: "money".to_string(),
defining_spec: crate::planning::semantics::TypeDefiningSpec::Import {
spec: Arc::clone(&dep_spec),
resolved_plan_hash: "a1b2c3d4".to_string(),
},
},
);
let mut named_types = BTreeMap::new();
named_types.insert("salary".to_string(), imported_type);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts: IndexMap::new(),
rules: Vec::new(),
meta: HashMap::new(),
named_types,
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
let recovered = deserialized
.named_types
.get("salary")
.expect("salary type should be present");
match &recovered.extends {
crate::planning::semantics::TypeExtends::Custom {
defining_spec:
crate::planning::semantics::TypeDefiningSpec::Import {
spec,
resolved_plan_hash,
},
..
} => {
assert_eq!(spec.name, "examples");
assert_eq!(resolved_plan_hash, "a1b2c3d4");
}
other => panic!(
"Expected imported defining_spec after round-trip, got {:?}",
other
),
}
}
#[test]
fn test_serialize_deserialize_plan_with_rules() {
use crate::planning::semantics::ExpressionKind;
let age_path = FactPath::new(vec![], "age".to_string());
let mut facts = IndexMap::new();
facts.insert(
age_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(0.into()),
source: test_source(),
is_default: false,
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "can_drive".to_string()),
name: "can_drive".to_string(),
branches: vec![Branch {
condition: Some(Expression::new(
ExpressionKind::Comparison(
Arc::new(create_fact_path_expr(age_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(18.into()))),
),
test_source(),
)),
result: create_literal_expr(create_boolean_literal(true)),
source: test_source(),
}],
needs_facts: BTreeSet::from([age_path]),
source: test_source(),
rule_type: primitive_boolean().clone(),
};
plan.rules.push(rule);
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.spec_name, plan.spec_name);
assert_eq!(deserialized.facts.len(), plan.facts.len());
assert_eq!(deserialized.rules.len(), plan.rules.len());
assert_eq!(deserialized.rules[0].name, "can_drive");
assert_eq!(deserialized.rules[0].branches.len(), 1);
assert_eq!(deserialized.rules[0].needs_facts.len(), 1);
}
#[test]
fn test_serialize_deserialize_plan_with_nested_fact_paths() {
use crate::planning::semantics::PathSegment;
let fact_path = FactPath {
segments: vec![PathSegment {
fact: "employee".to_string(),
spec: "private".to_string(),
}],
fact: "salary".to_string(),
};
let mut facts = IndexMap::new();
facts.insert(
fact_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(0.into()),
source: test_source(),
is_default: false,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.facts.len(), 1);
let (deserialized_path, _) = deserialized.facts.iter().next().unwrap();
assert_eq!(deserialized_path.segments.len(), 1);
assert_eq!(deserialized_path.segments[0].fact, "employee");
assert_eq!(deserialized_path.fact, "salary");
}
#[test]
fn test_serialize_deserialize_plan_with_multiple_fact_types() {
let name_path = FactPath::new(vec![], "name".to_string());
let age_path = FactPath::new(vec![], "age".to_string());
let active_path = FactPath::new(vec![], "active".to_string());
let mut facts = IndexMap::new();
facts.insert(
name_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_text_literal("Alice".to_string()),
source: test_source(),
is_default: false,
},
);
facts.insert(
age_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(30.into()),
source: test_source(),
is_default: false,
},
);
facts.insert(
active_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_boolean_literal(true),
source: test_source(),
is_default: false,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.facts.len(), 3);
assert_eq!(
deserialized.get_fact_value(&name_path).unwrap().value,
crate::planning::semantics::ValueKind::Text("Alice".to_string())
);
assert_eq!(
deserialized.get_fact_value(&age_path).unwrap().value,
crate::planning::semantics::ValueKind::Number(30.into())
);
assert_eq!(
deserialized.get_fact_value(&active_path).unwrap().value,
crate::planning::semantics::ValueKind::Boolean(true)
);
}
#[test]
fn test_serialize_deserialize_plan_with_multiple_branches() {
use crate::planning::semantics::ExpressionKind;
let points_path = FactPath::new(vec![], "points".to_string());
let mut facts = IndexMap::new();
facts.insert(
points_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(0.into()),
source: test_source(),
is_default: false,
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "tier".to_string()),
name: "tier".to_string(),
branches: vec![
Branch {
condition: None,
result: create_literal_expr(create_text_literal("bronze".to_string())),
source: test_source(),
},
Branch {
condition: Some(Expression::new(
ExpressionKind::Comparison(
Arc::new(create_fact_path_expr(points_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(100.into()))),
),
test_source(),
)),
result: create_literal_expr(create_text_literal("silver".to_string())),
source: test_source(),
},
Branch {
condition: Some(Expression::new(
ExpressionKind::Comparison(
Arc::new(create_fact_path_expr(points_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(500.into()))),
),
test_source(),
)),
result: create_literal_expr(create_text_literal("gold".to_string())),
source: test_source(),
},
],
needs_facts: BTreeSet::from([points_path]),
source: test_source(),
rule_type: primitive_text().clone(),
};
plan.rules.push(rule);
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.rules.len(), 1);
assert_eq!(deserialized.rules[0].branches.len(), 3);
assert!(deserialized.rules[0].branches[0].condition.is_none());
assert!(deserialized.rules[0].branches[1].condition.is_some());
assert!(deserialized.rules[0].branches[2].condition.is_some());
}
#[test]
fn test_serialize_deserialize_empty_plan() {
let plan = ExecutionPlan {
spec_name: "empty".to_string(),
facts: IndexMap::new(),
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.spec_name, "empty");
assert_eq!(deserialized.facts.len(), 0);
assert_eq!(deserialized.rules.len(), 0);
}
#[test]
fn test_serialize_deserialize_plan_with_arithmetic_expressions() {
use crate::planning::semantics::ExpressionKind;
let x_path = FactPath::new(vec![], "x".to_string());
let mut facts = IndexMap::new();
facts.insert(
x_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(0.into()),
source: test_source(),
is_default: false,
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "doubled".to_string()),
name: "doubled".to_string(),
branches: vec![Branch {
condition: None,
result: Expression::new(
ExpressionKind::Arithmetic(
Arc::new(create_fact_path_expr(x_path.clone())),
crate::parsing::ast::ArithmeticComputation::Multiply,
Arc::new(create_literal_expr(create_number_literal(2.into()))),
),
test_source(),
),
source: test_source(),
}],
needs_facts: BTreeSet::from([x_path]),
source: test_source(),
rule_type: crate::planning::semantics::primitive_number().clone(),
};
plan.rules.push(rule);
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(deserialized.rules.len(), 1);
match &deserialized.rules[0].branches[0].result.kind {
ExpressionKind::Arithmetic(left, op, right) => {
assert_eq!(*op, crate::parsing::ast::ArithmeticComputation::Multiply);
match &left.kind {
ExpressionKind::FactPath(_) => {}
_ => panic!("Expected FactPath in left operand"),
}
match &right.kind {
ExpressionKind::Literal(_) => {}
_ => panic!("Expected Literal in right operand"),
}
}
_ => panic!("Expected Arithmetic expression"),
}
}
#[test]
fn test_serialize_deserialize_round_trip_equality() {
use crate::planning::semantics::ExpressionKind;
let age_path = FactPath::new(vec![], "age".to_string());
let mut facts = IndexMap::new();
facts.insert(
age_path.clone(),
crate::planning::semantics::FactData::Value {
value: create_number_literal(0.into()),
source: test_source(),
is_default: false,
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
facts,
rules: Vec::new(),
meta: HashMap::new(),
named_types: BTreeMap::new(),
valid_from: None,
valid_to: None,
sources: IndexMap::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "is_adult".to_string()),
name: "is_adult".to_string(),
branches: vec![Branch {
condition: Some(Expression::new(
ExpressionKind::Comparison(
Arc::new(create_fact_path_expr(age_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(18.into()))),
),
test_source(),
)),
result: create_literal_expr(create_boolean_literal(true)),
source: test_source(),
}],
needs_facts: BTreeSet::from([age_path]),
source: test_source(),
rule_type: primitive_boolean().clone(),
};
plan.rules.push(rule);
let json = serde_json::to_string(&plan).expect("Should serialize");
let deserialized: ExecutionPlan = serde_json::from_str(&json).expect("Should deserialize");
let json2 = serde_json::to_string(&deserialized).expect("Should serialize again");
let deserialized2: ExecutionPlan =
serde_json::from_str(&json2).expect("Should deserialize again");
assert_eq!(deserialized2.spec_name, plan.spec_name);
assert_eq!(deserialized2.facts.len(), plan.facts.len());
assert_eq!(deserialized2.rules.len(), plan.rules.len());
assert_eq!(deserialized2.rules[0].name, plan.rules[0].name);
assert_eq!(
deserialized2.rules[0].branches.len(),
plan.rules[0].branches.len()
);
}
}