use crate::computation::UnitResolutionContext;
use crate::parsing::ast::{DateTimeValue, EffectiveDate, LemmaRepository, LemmaSpec, MetaValue};
use crate::parsing::source::Source;
use crate::planning::data_input::{parse_data_value, DataValueInput};
use crate::planning::graph::Graph;
use crate::planning::graph::ResolvedSpecTypes;
use crate::planning::normalize::{
build_decision_table, is_literal_bool_expression, normalize_expression,
};
use crate::planning::semantics::{
value_kind_matches_spec, DataDefinition, DataPath, Expression, LemmaType, LiteralValue,
RulePath, TypeSpecification, ValueKind,
};
use crate::Error;
use crate::ResourceLimits;
use indexmap::IndexMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub name: String,
pub effective_from: EffectiveDate,
pub source: String,
}
pub type SpecSources = Vec<SpecSource>;
#[derive(Debug, Clone)]
pub struct ExecutionPlan {
pub spec_name: String,
pub commentary: Option<String>,
pub data: IndexMap<DataPath, DataDefinition>,
pub rules: Vec<ExecutableRule>,
pub reference_evaluation_order: Vec<DataPath>,
pub meta: HashMap<String, MetaValue>,
pub resolved_types: ResolvedSpecTypes,
pub signature_index: crate::computation::arithmetic::SignatureIndex,
pub effective: EffectiveDate,
pub sources: SpecSources,
}
#[derive(Debug, Clone)]
pub struct ExecutionPlanSet {
pub spec_name: String,
pub plans: Vec<ExecutionPlan>,
}
impl ExecutionPlanSet {
#[must_use]
pub fn plan_at(&self, effective: &EffectiveDate) -> Option<&ExecutionPlan> {
for (i, plan) in self.plans.iter().enumerate() {
let from_ok = *effective >= plan.effective;
let to_ok = self
.plans
.get(i + 1)
.map(|next| *effective < next.effective)
.unwrap_or(true);
if from_ok && to_ok {
return Some(plan);
}
}
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutableRule {
pub path: RulePath,
pub name: String,
pub branches: Vec<Branch>,
pub normalized_branches: Vec<NormalizedBranch>,
pub needs_data: BTreeSet<DataPath>,
pub source: Source,
#[serde(with = "arc_lemma_type")]
pub rule_type: Arc<LemmaType>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
pub condition: Option<Expression>,
pub result: Expression,
pub source: Source,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizedBranch {
pub condition: Expression,
pub result: Expression,
}
mod arc_lemma_type {
use super::LemmaType;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::sync::Arc;
pub fn serialize<S>(value: &Arc<LemmaType>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
value.as_ref().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Arc<LemmaType>, D::Error>
where
D: Deserializer<'de>,
{
LemmaType::deserialize(deserializer).map(Arc::new)
}
}
pub(crate) fn build_execution_plan(
graph: &Graph,
resolved_types: &mut Vec<(Arc<LemmaRepository>, Arc<LemmaSpec>, ResolvedSpecTypes)>,
effective: &EffectiveDate,
) -> Result<ExecutionPlan, Vec<Error>> {
let execution_order = graph.execution_order();
let main_spec = graph.main_spec();
let main_idx = resolved_types
.iter()
.position(|(_, spec, _)| Arc::ptr_eq(spec, main_spec));
let mut sources: SpecSources = Vec::new();
for (repo, spec, _) in resolved_types.iter() {
if !sources.iter().any(|e| {
e.repository == repo.name
&& e.name == spec.name
&& e.effective_from == spec.effective_from
}) {
sources.push(SpecSource {
repository: repo.name.clone(),
name: spec.name.clone(),
effective_from: spec.effective_from.clone(),
source: crate::formatting::format_specs(&[spec.as_ref().clone()]),
});
}
}
let main_resolved_types = main_idx
.map(|idx| resolved_types.remove(idx).2)
.unwrap_or_default();
let data = graph.build_data(&main_resolved_types.resolved);
let signature_index = crate::planning::graph::build_signature_index(
&main_spec.name,
&main_resolved_types.unit_index,
)
.expect("BUG: signature_index build already validated during resolve_and_validate");
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 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(),
});
}
let unit_ctx = UnitResolutionContext::WithIndex(&main_resolved_types.unit_index);
let decision_table = build_decision_table(&rule_node.branches);
let mut normalized_branches = Vec::new();
let mut direct_data = HashSet::new();
for (condition, result) in decision_table {
let normalized_condition =
normalize_expression(&condition, Some(&unit_ctx)).map_err(|error| {
vec![Error::validation(
format!("failed to normalize decision table condition: {error}"),
Some(rule_node.source.clone()),
None::<String>,
)]
})?;
if is_literal_bool_expression(&normalized_condition, false) {
continue;
}
let normalized_result =
normalize_expression(&result, Some(&unit_ctx)).map_err(|error| {
vec![Error::validation(
format!("failed to normalize decision table result: {error}"),
Some(rule_node.source.clone()),
None::<String>,
)]
})?;
normalized_condition.collect_data_paths(&mut direct_data);
normalized_result.collect_data_paths(&mut direct_data);
normalized_branches.push(NormalizedBranch {
condition: normalized_condition,
result: normalized_result,
});
}
let mut needs_data: BTreeSet<DataPath> = direct_data.into_iter().collect();
for dep in &rule_node.depends_on_rules {
if let Some(&dep_idx) = path_to_index.get(dep) {
needs_data.extend(executable_rules[dep_idx].needs_data.iter().cloned());
}
}
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,
normalized_branches,
source: rule_node.source.clone(),
needs_data,
rule_type: Arc::clone(&rule_node.rule_type),
});
}
Ok(ExecutionPlan {
spec_name: main_spec.name.clone(),
commentary: main_spec.commentary.clone(),
data,
rules: executable_rules,
reference_evaluation_order: graph.reference_evaluation_order().to_vec(),
meta: main_spec
.meta_fields
.iter()
.map(|f| (f.key.clone(), f.value.clone()))
.collect(),
resolved_types: main_resolved_types,
signature_index,
effective: effective.clone(),
sources,
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DataEntry {
#[serde(rename = "type")]
pub lemma_type: LemmaType,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub bound_value: Option<LiteralValue>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub default: Option<LiteralValue>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpecSchema {
pub spec: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub commentary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub effective: Option<DateTimeValue>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub versions: Vec<DateTimeValue>,
pub data: indexmap::IndexMap<String, DataEntry>,
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 let Some(commentary) = &self.commentary {
write!(f, "\n {}", commentary)?;
}
if !self.meta.is_empty() {
write!(f, "\n\nMeta:")?;
let mut entries: Vec<(&String, &MetaValue)> = self.meta.iter().collect();
entries.sort_by_key(|(k, _)| *k);
for (key, value) in entries {
write!(f, "\n {}: {}", key, value)?;
}
}
if !self.data.is_empty() {
write!(f, "\n\nData:")?;
for (name, entry) in &self.data {
write!(f, "\n {} ({})", name, entry.lemma_type.specifications)?;
for line in type_detail_lines(&entry.lemma_type.specifications) {
write!(f, "\n {}", line)?;
}
let help = entry.lemma_type.specifications.help();
if !help.is_empty() {
write!(f, "\n help: {}", help)?;
}
if let Some(val) = &entry.bound_value {
write!(f, "\n value: {}", val)?;
}
if let Some(val) = &entry.default {
write!(f, "\n default: {}", val)?;
}
}
}
if !self.rules.is_empty() {
write!(f, "\n\nRules:")?;
for (name, rule_type) in &self.rules {
write!(f, "\n {} ({})", name, rule_type.specifications)?;
}
}
if self.data.is_empty() && self.rules.is_empty() {
write!(f, "\n (no data or rules)")?;
}
Ok(())
}
}
impl SpecSchema {
pub(crate) fn is_type_compatible(&self, other: &SpecSchema) -> bool {
for (name, entry) in &self.data {
if let Some(other_entry) = other.data.get(name) {
if entry.lemma_type != other_entry.lemma_type {
return false;
}
}
}
for (name, lt) in &self.rules {
if let Some(other_lt) = other.rules.get(name) {
if lt != other_lt {
return false;
}
}
}
true
}
}
pub fn type_detail_lines(spec: &TypeSpecification) -> Vec<String> {
use crate::computation::rational::rational_to_display_str;
let mut lines = Vec::new();
match spec {
TypeSpecification::Quantity {
minimum,
maximum,
decimals,
units,
..
} => {
let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
if !unit_names.is_empty() {
lines.push(format!("units: {}", unit_names.join(", ")));
}
if let Some(d) = decimals {
lines.push(format!("decimals: {}", d));
}
if let Some((magnitude, unit_name)) = minimum {
lines.push(format!(
"minimum: {} {}",
rational_to_display_str(magnitude),
unit_name
));
}
if let Some((magnitude, unit_name)) = maximum {
lines.push(format!(
"maximum: {} {}",
rational_to_display_str(magnitude),
unit_name
));
}
}
TypeSpecification::Number {
minimum,
maximum,
decimals,
..
} => {
if let Some(d) = decimals {
lines.push(format!("decimals: {}", d));
}
if let Some(v) = minimum {
lines.push(format!("minimum: {}", rational_to_display_str(v)));
}
if let Some(v) = maximum {
lines.push(format!("maximum: {}", rational_to_display_str(v)));
}
}
TypeSpecification::Ratio {
minimum,
maximum,
decimals,
units,
..
} => {
let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
if !unit_names.is_empty() {
lines.push(format!("units: {}", unit_names.join(", ")));
}
if let Some(d) = decimals {
lines.push(format!("decimals: {}", d));
}
if let Some(v) = minimum {
lines.push(format!("minimum: {}", rational_to_display_str(v)));
}
if let Some(v) = maximum {
lines.push(format!("maximum: {}", rational_to_display_str(v)));
}
}
TypeSpecification::Text {
options, length, ..
} => {
if let Some(l) = length {
lines.push(format!("length: {}", l));
}
if !options.is_empty() {
let quoted: Vec<String> = options.iter().map(|o| format!("\"{}\"", o)).collect();
lines.push(format!("options: {}", quoted.join(", ")));
}
}
TypeSpecification::Date {
minimum, maximum, ..
} => {
if let Some(v) = minimum {
lines.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
lines.push(format!("maximum: {}", v));
}
}
TypeSpecification::Time {
minimum, maximum, ..
} => {
if let Some(v) = minimum {
lines.push(format!("minimum: {}", v));
}
if let Some(v) = maximum {
lines.push(format!("maximum: {}", v));
}
}
TypeSpecification::QuantityRange { units, .. } => {
let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
if !unit_names.is_empty() {
lines.push(format!("units: {}", unit_names.join(", ")));
}
}
TypeSpecification::RatioRange { units, .. } => {
let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
if !unit_names.is_empty() {
lines.push(format!("units: {}", unit_names.join(", ")));
}
}
TypeSpecification::Boolean { .. }
| TypeSpecification::NumberRange { .. }
| TypeSpecification::DateRange { .. }
| TypeSpecification::TimeRange { .. }
| TypeSpecification::Veto { .. }
| TypeSpecification::Undetermined => {}
}
lines
}
impl ExecutionPlan {
pub(crate) fn expression_unit_index(&self) -> &HashMap<String, Arc<LemmaType>> {
&self.resolved_types.unit_index
}
pub fn schema(&self) -> SpecSchema {
let all_local_rules: Vec<String> = self
.rules
.iter()
.filter(|r| r.path.segments.is_empty())
.map(|r| r.name.clone())
.collect();
self.schema_for_rules(&all_local_rules)
.expect("BUG: all_local_rules sourced from self.rules")
}
pub fn interface_schema(&self) -> SpecSchema {
let mut data_entries: Vec<(usize, String, DataEntry)> = self
.data
.iter()
.filter(|(_, data)| data.schema_type().is_some())
.map(|(path, data)| {
let lemma_type = data
.schema_type()
.expect("BUG: filter above ensured schema_type is Some")
.clone();
let bound_value = data.bound_value().cloned();
let default = data.default_suggestion();
(
data.source().span.start,
path.input_key(),
DataEntry {
lemma_type,
bound_value,
default,
},
)
})
.collect();
data_entries.sort_by_key(|(pos, _, _)| *pos);
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(),
commentary: self.commentary.clone(),
effective: self.effective.as_ref().cloned(),
versions: Vec::new(),
data: data_entries
.into_iter()
.map(|(_, name, data)| (name, data))
.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_data = 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_data.extend(rule.needs_data.iter().cloned());
rule_entries.push((rule.name.clone(), (*rule.rule_type).clone()));
}
let mut data_entries: Vec<(usize, String, DataEntry)> = self
.data
.iter()
.filter(|(path, _)| needed_data.contains(path))
.filter_map(|(path, data)| {
let lemma_type = data.schema_type()?.clone();
let bound_value = data.bound_value().cloned();
let default = data.default_suggestion();
Some((
data.source().span.start,
path.input_key(),
DataEntry {
lemma_type,
bound_value,
default,
},
))
})
.collect();
data_entries.sort_by_key(|(pos, _, _)| *pos);
let data_entries: Vec<(String, DataEntry)> = data_entries
.into_iter()
.map(|(_, name, data)| (name, data))
.collect();
Ok(SpecSchema {
spec: self.spec_name.clone(),
commentary: self.commentary.clone(),
effective: self.effective.as_ref().cloned(),
versions: Vec::new(),
data: data_entries.into_iter().collect(),
rules: rule_entries.into_iter().collect(),
meta: self.meta.clone(),
})
}
pub fn get_data_path_by_str(&self, name: &str) -> Option<&DataPath> {
let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
self.data
.keys()
.find(|path| path.input_key() == canonical_name)
}
pub fn get_rule(&self, name: &str) -> Option<&ExecutableRule> {
let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
self.rules
.iter()
.find(|r| r.name == canonical_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_data_value(&self, path: &DataPath) -> Option<&LiteralValue> {
self.data.get(path).and_then(|d| d.value())
}
pub fn set_data_values(
mut self,
values: std::collections::HashMap<String, DataValueInput>,
limits: &ResourceLimits,
) -> Result<Self, Error> {
for (name, raw_value) in values {
let data_path = self.get_data_path_by_str(&name).ok_or_else(|| {
let available: Vec<String> = self.data.keys().map(|p| p.input_key()).collect();
Error::request(
format!(
"Data '{}' not found. Available data: {}",
name,
available.join(", ")
),
None::<String>,
)
})?;
let data_path = data_path.clone();
let data_definition = self
.data
.get(&data_path)
.expect("BUG: data_path was just resolved from self.data, must exist");
let data_source = data_definition.source().clone();
let type_arc = match data_definition {
DataDefinition::TypeDeclaration { resolved_type, .. }
| DataDefinition::Reference { resolved_type, .. } => Arc::clone(resolved_type),
DataDefinition::Value { value, .. } => Arc::clone(&value.lemma_type),
DataDefinition::Import { .. } => {
return Err(Error::request(
format!(
"Data '{}' is a spec reference; cannot provide a value.",
name
),
None::<String>,
));
}
DataDefinition::Violated { .. } => {
unreachable!(
"BUG: Violated data '{}' cannot appear before set_data_values on a fresh plan clone",
name
);
}
};
let literal_value = match parse_data_value(&raw_value, &type_arc, &data_source) {
Ok(value) => value,
Err(error) => {
self.data.insert(
data_path,
DataDefinition::Violated {
reason: error.message().to_string(),
source: data_source,
},
);
continue;
}
};
let size = literal_value.byte_size();
if size > limits.max_data_value_bytes {
return Err(Error::resource_limit_exceeded(
"max_data_value_bytes",
limits.max_data_value_bytes.to_string(),
size.to_string(),
format!(
"Reduce the size of data values to {} bytes or less",
limits.max_data_value_bytes
),
Some(data_source.clone()),
None,
None,
)
.with_related_data(&name));
}
if let Err(msg) = validate_value_against_type(type_arc.as_ref(), &literal_value) {
self.data.insert(
data_path,
DataDefinition::Violated {
reason: msg,
source: data_source,
},
);
continue;
}
self.data.insert(
data_path,
DataDefinition::Value {
value: literal_value,
source: data_source,
},
);
}
Ok(self)
}
#[must_use]
pub fn with_defaults(mut self) -> Self {
let promotions: Vec<(DataPath, DataDefinition)> = self
.data
.iter()
.filter_map(|(path, def)| {
if let DataDefinition::TypeDeclaration {
declared_default: Some(dv),
resolved_type,
source,
} = def
{
Some((
path.clone(),
DataDefinition::Value {
value: LiteralValue {
value: dv.clone(),
lemma_type: Arc::clone(resolved_type),
},
source: source.clone(),
},
))
} else {
None
}
})
.collect();
for (path, def) in promotions {
self.data.insert(path, def);
}
self
}
}
pub(crate) fn validate_value_against_type(
expected_type: &LemmaType,
value: &LiteralValue,
) -> Result<(), String> {
use crate::computation::rational::{commit_rational_to_decimal, RationalInteger};
use crate::planning::semantics::TypeSpecification;
fn exceeds_decimal_places(magnitude: &RationalInteger, max_decimals: u8) -> bool {
match commit_rational_to_decimal(magnitude) {
Ok(decimal) => decimal.scale() > u32::from(max_decimals),
Err(_) => true,
}
}
fn format_rational(r: &RationalInteger, decimals: Option<u8>) -> String {
use crate::computation::rational::rational_to_display_str;
match commit_rational_to_decimal(r) {
Ok(decimal) => match decimals {
Some(dp) => {
let rounded = decimal.round_dp(u32::from(dp));
format!("{:.prec$}", rounded, prec = dp as usize)
}
None => decimal.normalize().to_string(),
},
Err(_) => rational_to_display_str(r),
}
}
match (&expected_type.specifications, &value.value) {
(
TypeSpecification::Number {
minimum,
maximum,
decimals,
..
},
ValueKind::Number(n),
) => {
if let Some(d) = decimals {
if exceeds_decimal_places(n, *d) {
return Err(format!(
"{} exceeds decimals constraint {d}",
format_rational(n, *decimals)
));
}
}
if let Some(min) = minimum {
if n < min {
return Err(format!(
"{} is below minimum {}",
format_rational(n, *decimals),
format_rational(min, *decimals)
));
}
}
if let Some(max) = maximum {
if n > max {
return Err(format!(
"{} is above maximum {}",
format_rational(n, *decimals),
format_rational(max, *decimals)
));
}
}
Ok(())
}
(
TypeSpecification::Quantity {
minimum,
maximum,
decimals,
units,
..
},
ValueKind::Quantity(magnitude, signature),
) => {
use crate::computation::rational::checked_div;
use crate::planning::semantics::quantity_declared_bound_canonical;
let unit = signature
.first()
.map(|(n, _)| n.as_str())
.expect("BUG: Quantity value has empty signature in execution plan validation");
let quantity_unit = units.get(unit)?;
let factor = &quantity_unit.factor;
let in_unit = checked_div(magnitude, factor).map_err(|failure| {
format!("cannot de-canonicalize quantity for validation: {failure}")
})?;
if let Some(d) = decimals {
if exceeds_decimal_places(&in_unit, *d) {
return Err(format!(
"{} {unit} exceeds decimals constraint {d}",
format_rational(&in_unit, *decimals)
));
}
}
if let Some(bound) = minimum {
let canonical_min = quantity_declared_bound_canonical(
bound,
units,
expected_type.name().as_str(),
"minimum",
)?;
if magnitude < &canonical_min {
let min_in_unit = checked_div(&canonical_min, factor).map_err(|failure| {
format!("cannot de-canonicalize minimum for validation: {failure}")
})?;
let value_display =
format!("{} {}", format_rational(&in_unit, *decimals), unit);
let bound_display = format!(
"{} {}",
format_rational(&min_in_unit, *decimals),
quantity_unit.name
);
return Err(format!("{value_display} is below minimum {bound_display}"));
}
}
if let Some(bound) = maximum {
let canonical_max = quantity_declared_bound_canonical(
bound,
units,
expected_type.name().as_str(),
"maximum",
)?;
if magnitude > &canonical_max {
let max_in_unit = checked_div(&canonical_max, factor).map_err(|failure| {
format!("cannot de-canonicalize maximum for validation: {failure}")
})?;
let value_display =
format!("{} {}", format_rational(&in_unit, *decimals), unit);
let bound_display = format!(
"{} {}",
format_rational(&max_in_unit, *decimals),
quantity_unit.name
);
return Err(format!("{value_display} is above maximum {bound_display}"));
}
}
Ok(())
}
(
TypeSpecification::Text {
length, options, ..
},
ValueKind::Text(s),
) => {
let len = s.chars().count();
if let Some(exact) = length {
if len != *exact {
return Err(format!(
"'{}' has length {} but required length is {}",
s, len, exact
));
}
}
if !options.is_empty() && !options.iter().any(|opt| opt == s) {
return Err(format!(
"'{}' is not in allowed options: {}",
s,
options.join(", ")
));
}
Ok(())
}
(
TypeSpecification::Ratio {
minimum,
maximum,
decimals,
units,
..
},
ValueKind::Ratio(r, unit_name),
) => {
use crate::computation::rational::checked_mul;
if let Some(d) = decimals {
if exceeds_decimal_places(r, *d) {
return Err(format!(
"{} exceeds decimals constraint {d}",
format_rational(r, *decimals)
));
}
}
if let Some(type_minimum) = minimum {
if r < type_minimum {
let message = match unit_name.as_deref() {
Some(unit) => {
let ratio_unit = units.get(unit)?;
let value_per_unit = checked_mul(r, &ratio_unit.value)
.map_err(|failure| failure.to_string())?;
let bound_per_unit = ratio_unit.minimum.expect(
"BUG: RatioUnit.minimum missing after type minimum set by sync_ratio_units_from_canonical",
);
format!(
"{} {unit} is below minimum {} {unit}",
format_rational(&value_per_unit, *decimals),
format_rational(&bound_per_unit, *decimals),
)
}
None => format!(
"{} is below minimum {}",
format_rational(r, *decimals),
format_rational(type_minimum, *decimals),
),
};
return Err(message);
}
}
if let Some(type_maximum) = maximum {
if r > type_maximum {
let message = match unit_name.as_deref() {
Some(unit) => {
let ratio_unit = units.get(unit)?;
let value_per_unit = checked_mul(r, &ratio_unit.value)
.map_err(|failure| failure.to_string())?;
let bound_per_unit = ratio_unit.maximum.expect(
"BUG: RatioUnit.maximum missing after type maximum set by sync_ratio_units_from_canonical",
);
format!(
"{} {unit} is above maximum {} {unit}",
format_rational(&value_per_unit, *decimals),
format_rational(&bound_per_unit, *decimals),
)
}
None => format!(
"{} is above maximum {}",
format_rational(r, *decimals),
format_rational(type_maximum, *decimals),
),
};
return Err(message);
}
}
Ok(())
}
(
TypeSpecification::Date {
minimum, maximum, ..
},
ValueKind::Date(dt),
) => {
use crate::planning::semantics::{compare_semantic_dates, date_time_to_semantic};
use std::cmp::Ordering;
if let Some(min) = minimum {
let min_sem = date_time_to_semantic(min);
if compare_semantic_dates(dt, &min_sem) == Ordering::Less {
return Err(format!("{} is below minimum {}", dt, min));
}
}
if let Some(max) = maximum {
let max_sem = date_time_to_semantic(max);
if compare_semantic_dates(dt, &max_sem) == Ordering::Greater {
return Err(format!("{} is above maximum {}", dt, max));
}
}
Ok(())
}
(
TypeSpecification::Time {
minimum, maximum, ..
},
ValueKind::Time(t),
) => {
use crate::planning::semantics::{compare_semantic_times, time_to_semantic};
use std::cmp::Ordering;
if let Some(min) = minimum {
let min_sem = time_to_semantic(min);
if compare_semantic_times(t, &min_sem) == Ordering::Less {
return Err(format!("{} is below minimum {}", t, min));
}
}
if let Some(max) = maximum {
let max_sem = time_to_semantic(max);
if compare_semantic_times(t, &max_sem) == Ordering::Greater {
return Err(format!("{} is above maximum {}", t, max));
}
}
Ok(())
}
(TypeSpecification::Boolean { .. }, ValueKind::Boolean(_))
| (TypeSpecification::NumberRange { .. }, ValueKind::Range(_, _))
| (TypeSpecification::DateRange { .. }, ValueKind::Range(_, _))
| (TypeSpecification::TimeRange { .. }, ValueKind::Range(_, _))
| (TypeSpecification::QuantityRange { .. }, ValueKind::Range(_, _))
| (TypeSpecification::RatioRange { .. }, ValueKind::Range(_, _))
| (TypeSpecification::Veto { .. }, _)
| (TypeSpecification::Undetermined, _) => Ok(()),
(spec, value_kind) if !value_kind_matches_spec(value_kind, spec) => unreachable!(
"BUG: validate_value_against_type called with mismatched type/value: \
spec={:?}, value={:?} — typing must be enforced before validation",
spec, value_kind
),
(_, _) => Ok(()),
}
}
pub(crate) fn validate_literal_data_against_types(plan: &ExecutionPlan) -> Vec<Error> {
let mut errors = Vec::new();
for (data_path, data_definition) in &plan.data {
let (expected_type, lit) = match data_definition {
DataDefinition::Value { value, .. } => (&value.lemma_type, value),
DataDefinition::TypeDeclaration { .. }
| DataDefinition::Import { .. }
| DataDefinition::Reference { .. }
| DataDefinition::Violated { .. } => continue,
};
if let Err(msg) = validate_value_against_type(expected_type, lit) {
let source = data_definition.source().clone();
errors.push(Error::validation(
format!(
"Invalid value for data {} (expected {}): {}",
data_path,
expected_type.name().as_str(),
msg
),
Some(source),
None::<String>,
));
}
}
errors
}
fn collect_unit_conversion_targets(expression: &Expression, units: &mut BTreeSet<String>) {
use crate::planning::semantics::{ExpressionKind, SemanticConversionTarget};
match &expression.kind {
ExpressionKind::UnitConversion(inner, SemanticConversionTarget::Unit { unit_name }) => {
units.insert(unit_name.clone());
collect_unit_conversion_targets(inner, units);
}
ExpressionKind::UnitConversion(inner, SemanticConversionTarget::Type(_))
| ExpressionKind::LogicalNegation(inner, _)
| ExpressionKind::MathematicalComputation(_, inner)
| ExpressionKind::PastFutureRange(_, inner) => {
collect_unit_conversion_targets(inner, units);
}
ExpressionKind::LogicalAnd(left, right) | ExpressionKind::LogicalOr(left, right) => {
collect_unit_conversion_targets(left, units);
collect_unit_conversion_targets(right, units);
}
ExpressionKind::Arithmetic(left, _, right)
| ExpressionKind::Comparison(left, _, right)
| ExpressionKind::RangeLiteral(left, right)
| ExpressionKind::RangeContainment(left, right) => {
collect_unit_conversion_targets(left, units);
collect_unit_conversion_targets(right, units);
}
ExpressionKind::DateRelative(_, date_expr) => {
collect_unit_conversion_targets(date_expr, units);
}
ExpressionKind::DateCalendar(_, _, date_expr) => {
collect_unit_conversion_targets(date_expr, units);
}
ExpressionKind::ResultIsVeto(operand) => {
collect_unit_conversion_targets(operand, units);
}
ExpressionKind::Literal(_)
| ExpressionKind::DataPath(_)
| ExpressionKind::RulePath(_)
| ExpressionKind::Veto(_)
| ExpressionKind::Now => {}
}
}
pub(crate) fn validate_unit_index_references(plan: &ExecutionPlan) -> Result<(), Error> {
let mut required_units = BTreeSet::new();
for rule in &plan.rules {
for branch in &rule.normalized_branches {
collect_unit_conversion_targets(&branch.result, &mut required_units);
collect_unit_conversion_targets(&branch.condition, &mut required_units);
}
}
for unit_name in required_units {
if plan.resolved_types.unit_index.contains_key(&unit_name) {
continue;
}
return Err(Error::validation(
format!("Unknown unit '{unit_name}' in execution plan unit index."),
None::<Source>,
Some(plan.spec_name.clone()),
));
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionPlanSerialized {
pub spec_name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub commentary: Option<String>,
#[serde(
serialize_with = "serialize_resolved_data_value_map",
deserialize_with = "deserialize_resolved_data_value_map"
)]
pub data: IndexMap<DataPath, DataDefinition>,
#[serde(default)]
pub rules: Vec<ExecutableRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reference_evaluation_order: Vec<DataPath>,
#[serde(default)]
pub meta: HashMap<String, MetaValue>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub unit_index: HashMap<String, Arc<LemmaType>>,
pub effective: EffectiveDate,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: SpecSources,
}
impl From<&ExecutionPlan> for ExecutionPlanSerialized {
fn from(plan: &ExecutionPlan) -> Self {
Self {
spec_name: plan.spec_name.clone(),
commentary: plan.commentary.clone(),
data: plan.data.clone(),
rules: plan.rules.clone(),
reference_evaluation_order: plan.reference_evaluation_order.clone(),
meta: plan.meta.clone(),
unit_index: plan.resolved_types.unit_index.clone(),
effective: plan.effective.clone(),
sources: plan.sources.clone(),
}
}
}
impl TryFrom<ExecutionPlanSerialized> for ExecutionPlan {
type Error = crate::Error;
fn try_from(serialized: ExecutionPlanSerialized) -> Result<Self, Self::Error> {
let signature_index = crate::planning::graph::build_signature_index(
&serialized.spec_name,
&serialized.unit_index,
)?;
Ok(Self {
spec_name: serialized.spec_name,
commentary: serialized.commentary,
data: serialized.data,
rules: serialized.rules,
reference_evaluation_order: serialized.reference_evaluation_order,
meta: serialized.meta,
resolved_types: ResolvedSpecTypes {
unit_index: serialized.unit_index,
..ResolvedSpecTypes::default()
},
signature_index,
effective: serialized.effective,
sources: serialized.sources,
})
}
}
fn serialize_resolved_data_value_map<S>(
map: &IndexMap<DataPath, DataDefinition>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let entries: Vec<(&DataPath, &DataDefinition)> = map.iter().collect();
entries.serialize(serializer)
}
fn deserialize_resolved_data_value_map<'de, D>(
deserializer: D,
) -> Result<IndexMap<DataPath, DataDefinition>, D::Error>
where
D: Deserializer<'de>,
{
let entries: Vec<(DataPath, DataDefinition)> = Vec::deserialize(deserializer)?;
Ok(entries.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::computation::rational::{rational_zero, RationalInteger};
use crate::parsing::ast::DateTimeValue;
use crate::planning::semantics::{
primitive_boolean, primitive_text, DataPath, LiteralValue, PathSegment, RulePath,
};
use crate::Engine;
use serde_json;
use std::str::FromStr;
use std::sync::Arc;
fn default_limits() -> ResourceLimits {
ResourceLimits::default()
}
fn roundtrip_execution_plan(plan: &ExecutionPlan) -> ExecutionPlan {
let serialized = ExecutionPlanSerialized::from(plan);
let json = serde_json::to_string(&serialized).expect("Should serialize");
let back: ExecutionPlanSerialized =
serde_json::from_str(&json).expect("Should deserialize");
ExecutionPlan::try_from(back).expect("Should reconstruct")
}
fn input_data(pairs: &[(&str, &str)]) -> HashMap<String, DataValueInput> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), DataValueInput::convenience(*v)))
.collect()
}
#[test]
fn test_with_raw_values() {
let mut engine = Engine::new();
engine
.load(
r#"
spec test
data age: number -> default 25
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"test.lemma",
))),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan(None, "test", Some(&now)).unwrap().clone();
let data_path = DataPath::new(vec![], "age".to_string());
let values = input_data(&[("age", "30")]);
let updated_plan = plan.set_data_values(values, &default_limits()).unwrap();
let updated_value = updated_plan.get_data_value(&data_path).unwrap();
match &updated_value.value {
crate::planning::semantics::ValueKind::Number(n) => {
assert_eq!(*n, RationalInteger::new(30, 1));
}
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
data age: number
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"test.lemma",
))),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan(None, "test", Some(&now)).unwrap().clone();
let values = input_data(&[("age", "thirty")]);
let updated = plan.set_data_values(values, &default_limits()).unwrap();
let data_path = DataPath::new(vec![], "age".to_string());
match updated.data.get(&data_path) {
Some(crate::planning::semantics::DataDefinition::Violated { reason, .. }) => {
assert!(
reason.contains("number"),
"type mismatch must record violation reason, got: {reason}"
);
}
other => panic!("expected Violated data for age=thirty, got: {other:?}"),
}
}
#[test]
fn test_with_raw_values_unknown_data() {
let mut engine = Engine::new();
engine
.load(
r#"
spec test
data known: number
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"test.lemma",
))),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan(None, "test", Some(&now)).unwrap().clone();
let values = input_data(&[("unknown", "30")]);
assert!(plan.set_data_values(values, &default_limits()).is_err());
}
#[test]
fn test_with_raw_values_nested() {
let mut engine = Engine::new();
engine
.load(
r#"
spec private
data base_price: number
spec test
uses rules: private
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"test.lemma",
))),
)
.unwrap();
let now = DateTimeValue::now();
let plan = engine.get_plan(None, "test", Some(&now)).unwrap().clone();
let values = input_data(&[("rules.base_price", "100")]);
let updated_plan = plan.set_data_values(values, &default_limits()).unwrap();
let data_path = DataPath {
segments: vec![PathSegment {
data: "rules".to_string(),
spec: "private".to_string(),
}],
data: "base_price".to_string(),
};
let updated_value = updated_plan.get_data_value(&data_path).unwrap();
match &updated_value.value {
crate::planning::semantics::ValueKind::Number(n) => {
assert_eq!(*n, RationalInteger::new(100, 1));
}
other => panic!("Expected number literal, got {:?}", other),
}
}
fn test_source() -> Source {
use crate::parsing::ast::Span;
Source::new(
crate::parsing::source::SourceType::Volatile,
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_data_path_expr(path: DataPath) -> Expression {
Expression::new(
crate::planning::semantics::ExpressionKind::DataPath(path),
test_source(),
)
}
fn create_number_literal(n: rust_decimal::Decimal) -> LiteralValue {
LiteralValue::number_from_decimal(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 data_path = DataPath::new(vec![], "x".to_string());
let max10 = crate::planning::semantics::LemmaType::primitive(
crate::planning::semantics::TypeSpecification::Number {
minimum: None,
maximum: Some(RationalInteger::new(10, 1)),
decimals: None,
help: String::new(),
},
);
let source = Source::new(
crate::parsing::source::SourceType::Volatile,
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
);
let mut data = IndexMap::new();
data.insert(
data_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: crate::planning::semantics::LiteralValue::number_with_type(
0.into(),
Arc::new(max10.clone()),
),
source: source.clone(),
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let values = input_data(&[("x", "11")]);
let updated = plan.set_data_values(values, &default_limits()).unwrap();
match updated.data.get(&data_path) {
Some(crate::planning::semantics::DataDefinition::Violated { reason, .. }) => {
assert!(
reason.contains("maximum") || reason.contains("10"),
"x=11 must violate maximum 10, got: {reason}"
);
}
other => panic!("expected Violated data for x=11, got: {other:?}"),
}
}
#[test]
fn with_values_should_enforce_text_enum_options() {
let data_path = DataPath::new(vec![], "tier".to_string());
let tier = crate::planning::semantics::LemmaType::primitive(
crate::planning::semantics::TypeSpecification::Text {
length: None,
options: vec!["silver".to_string(), "gold".to_string()],
help: String::new(),
},
);
let source = Source::new(
crate::parsing::source::SourceType::Volatile,
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
);
let mut data = IndexMap::new();
data.insert(
data_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: crate::planning::semantics::LiteralValue::text_with_type(
"silver".to_string(),
Arc::new(tier.clone()),
),
source,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let values = input_data(&[("tier", "platinum")]);
let updated = plan.set_data_values(values, &default_limits()).unwrap();
match updated.data.get(&data_path) {
Some(crate::planning::semantics::DataDefinition::Violated { reason, .. }) => {
assert!(
reason.contains("allowed options") || reason.contains("platinum"),
"invalid enum must record violation, got: {reason}"
);
}
other => panic!("expected Violated data for tier=platinum, got: {other:?}"),
}
}
#[test]
fn with_values_should_enforce_quantity_decimals() {
let data_path = DataPath::new(vec![], "price".to_string());
let money = crate::planning::semantics::LemmaType::primitive(
crate::planning::semantics::TypeSpecification::Quantity {
minimum: None,
maximum: None,
decimals: Some(2),
units: crate::planning::semantics::QuantityUnits::from(vec![
crate::planning::semantics::QuantityUnit::from_decimal_factor(
"eur".to_string(),
rust_decimal::Decimal::from_str("1.0").unwrap(),
Vec::new(),
)
.expect("eur unit factor must be exact decimal"),
]),
traits: Vec::new(),
decomposition: None,
help: String::new(),
},
);
let source = Source::new(
crate::parsing::source::SourceType::Volatile,
crate::parsing::ast::Span {
start: 0,
end: 0,
line: 1,
col: 0,
},
);
let mut data = IndexMap::new();
data.insert(
data_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: crate::planning::semantics::LiteralValue::quantity_with_type(
rational_zero(),
"eur".to_string(),
Arc::new(money.clone()),
),
source,
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let values = input_data(&[("price", "1.234 eur")]);
let updated = plan.set_data_values(values, &default_limits()).unwrap();
match updated.data.get(&data_path) {
Some(crate::planning::semantics::DataDefinition::Violated { reason, .. }) => {
assert!(
reason.contains("decimals") || reason.contains("decimal"),
"1.234 eur must violate decimals=2, got: {reason}"
);
}
other => panic!("expected Violated data for price=1.234 eur, got: {other:?}"),
}
}
#[test]
fn test_serialize_deserialize_execution_plan() {
let data_path = DataPath {
segments: vec![],
data: "age".to_string(),
};
let mut data = IndexMap::new();
data.insert(
data_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(0.into()),
source: test_source(),
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let deserialized = roundtrip_execution_plan(&plan);
assert_eq!(deserialized.spec_name, plan.spec_name);
assert_eq!(deserialized.data.len(), plan.data.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::quantity(),
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),
},
},
);
let salary_path = DataPath::new(vec![], "salary".to_string());
let mut data = IndexMap::new();
data.insert(
salary_path,
crate::planning::semantics::DataDefinition::TypeDeclaration {
resolved_type: Arc::new(imported_type),
declared_default: None,
source: test_source(),
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let deserialized = roundtrip_execution_plan(&plan);
let recovered = deserialized
.data
.get(&DataPath::new(vec![], "salary".to_string()))
.and_then(|d| d.schema_type())
.expect("salary type should be present in plan.data");
match &recovered.extends {
crate::planning::semantics::TypeExtends::Custom {
defining_spec: crate::planning::semantics::TypeDefiningSpec::Import { spec },
..
} => {
assert_eq!(spec.name, "examples");
}
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 = DataPath::new(vec![], "age".to_string());
let mut data = IndexMap::new();
data.insert(
age_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(0.into()),
source: test_source(),
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "can_drive".to_string()),
name: "can_drive".to_string(),
branches: vec![{
let result = create_literal_expr(create_boolean_literal(true));
let condition = Expression::new(
ExpressionKind::Comparison(
Arc::new(create_data_path_expr(age_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(18.into()))),
),
test_source(),
);
Branch {
condition: Some(condition.clone()),
result: result.clone(),
source: test_source(),
}
}],
normalized_branches: vec![{
let result = create_literal_expr(create_boolean_literal(true));
let condition = Expression::new(
ExpressionKind::Comparison(
Arc::new(create_data_path_expr(age_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(18.into()))),
),
test_source(),
);
NormalizedBranch { condition, result }
}],
needs_data: BTreeSet::from([age_path]),
source: test_source(),
rule_type: Arc::new(primitive_boolean().clone()),
};
plan.rules.push(rule);
let deserialized = roundtrip_execution_plan(&plan);
assert_eq!(deserialized.spec_name, plan.spec_name);
assert_eq!(deserialized.data.len(), plan.data.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_data.len(), 1);
}
#[test]
fn test_serialize_deserialize_plan_with_nested_data_paths() {
use crate::planning::semantics::PathSegment;
let data_path = DataPath {
segments: vec![PathSegment {
data: "employee".to_string(),
spec: "private".to_string(),
}],
data: "salary".to_string(),
};
let mut data = IndexMap::new();
data.insert(
data_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(0.into()),
source: test_source(),
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let deserialized = roundtrip_execution_plan(&plan);
assert_eq!(deserialized.data.len(), 1);
let (deserialized_path, _) = deserialized.data.iter().next().unwrap();
assert_eq!(deserialized_path.segments.len(), 1);
assert_eq!(deserialized_path.segments[0].data, "employee");
assert_eq!(deserialized_path.data, "salary");
}
#[test]
fn test_serialize_deserialize_plan_with_multiple_data_types() {
let name_path = DataPath::new(vec![], "name".to_string());
let age_path = DataPath::new(vec![], "age".to_string());
let active_path = DataPath::new(vec![], "active".to_string());
let mut data = IndexMap::new();
data.insert(
name_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_text_literal("Alice".to_string()),
source: test_source(),
},
);
data.insert(
age_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(30.into()),
source: test_source(),
},
);
data.insert(
active_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_boolean_literal(true),
source: test_source(),
},
);
let plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let deserialized = roundtrip_execution_plan(&plan);
assert_eq!(deserialized.data.len(), 3);
assert_eq!(
deserialized.get_data_value(&name_path).unwrap().value,
crate::planning::semantics::ValueKind::Text("Alice".to_string())
);
assert_eq!(
deserialized.get_data_value(&age_path).unwrap().value,
crate::planning::semantics::ValueKind::Number(30.into())
);
assert_eq!(
deserialized.get_data_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 = DataPath::new(vec![], "points".to_string());
let mut data = IndexMap::new();
data.insert(
points_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(0.into()),
source: test_source(),
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "tier".to_string()),
name: "tier".to_string(),
branches: vec![
{
let result = create_literal_expr(create_text_literal("bronze".to_string()));
Branch {
condition: None,
result: result.clone(),
source: test_source(),
}
},
{
let result = create_literal_expr(create_text_literal("silver".to_string()));
Branch {
condition: Some(Expression::new(
ExpressionKind::Comparison(
Arc::new(create_data_path_expr(points_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(100.into()))),
),
test_source(),
)),
result: result.clone(),
source: test_source(),
}
},
{
let result = create_literal_expr(create_text_literal("gold".to_string()));
Branch {
condition: Some(Expression::new(
ExpressionKind::Comparison(
Arc::new(create_data_path_expr(points_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(500.into()))),
),
test_source(),
)),
result: result.clone(),
source: test_source(),
}
},
],
normalized_branches: vec![
NormalizedBranch {
condition: create_literal_expr(create_boolean_literal(true)),
result: create_literal_expr(create_text_literal("bronze".to_string())),
},
NormalizedBranch {
condition: create_literal_expr(create_boolean_literal(true)),
result: create_literal_expr(create_text_literal("silver".to_string())),
},
NormalizedBranch {
condition: create_literal_expr(create_boolean_literal(true)),
result: create_literal_expr(create_text_literal("gold".to_string())),
},
],
needs_data: BTreeSet::from([points_path]),
source: test_source(),
rule_type: Arc::new(primitive_text().clone()),
};
plan.rules.push(rule);
let deserialized = roundtrip_execution_plan(&plan);
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(),
commentary: None,
data: IndexMap::new(),
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let deserialized = roundtrip_execution_plan(&plan);
assert_eq!(deserialized.spec_name, "empty");
assert_eq!(deserialized.data.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 = DataPath::new(vec![], "x".to_string());
let mut data = IndexMap::new();
data.insert(
x_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(0.into()),
source: test_source(),
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "doubled".to_string()),
name: "doubled".to_string(),
branches: vec![{
let result = Expression::new(
ExpressionKind::Arithmetic(
Arc::new(create_data_path_expr(x_path.clone())),
crate::parsing::ast::ArithmeticComputation::Multiply,
Arc::new(create_literal_expr(create_number_literal(2.into()))),
),
test_source(),
);
Branch {
condition: None,
result: result.clone(),
source: test_source(),
}
}],
normalized_branches: vec![{
let result = Expression::new(
ExpressionKind::Arithmetic(
Arc::new(create_data_path_expr(x_path.clone())),
crate::parsing::ast::ArithmeticComputation::Multiply,
Arc::new(create_literal_expr(create_number_literal(2.into()))),
),
test_source(),
);
NormalizedBranch {
condition: create_literal_expr(create_boolean_literal(true)),
result,
}
}],
needs_data: BTreeSet::from([x_path]),
source: test_source(),
rule_type: Arc::new(crate::planning::semantics::primitive_number().clone()),
};
plan.rules.push(rule);
let deserialized = roundtrip_execution_plan(&plan);
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::DataPath(_) => {}
_ => panic!("Expected DataPath 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 = DataPath::new(vec![], "age".to_string());
let mut data = IndexMap::new();
data.insert(
age_path.clone(),
crate::planning::semantics::DataDefinition::Value {
value: create_number_literal(0.into()),
source: test_source(),
},
);
let mut plan = ExecutionPlan {
spec_name: "test".to_string(),
commentary: None,
data,
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective: EffectiveDate::Origin,
sources: Vec::new(),
};
let rule = ExecutableRule {
path: RulePath::new(vec![], "is_adult".to_string()),
name: "is_adult".to_string(),
branches: vec![{
let result = create_literal_expr(create_boolean_literal(true));
let condition = Expression::new(
ExpressionKind::Comparison(
Arc::new(create_data_path_expr(age_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(18.into()))),
),
test_source(),
);
Branch {
condition: Some(condition.clone()),
result: result.clone(),
source: test_source(),
}
}],
normalized_branches: vec![{
let result = create_literal_expr(create_boolean_literal(true));
let condition = Expression::new(
ExpressionKind::Comparison(
Arc::new(create_data_path_expr(age_path.clone())),
crate::parsing::ast::ComparisonComputation::GreaterThanOrEqual,
Arc::new(create_literal_expr(create_number_literal(18.into()))),
),
test_source(),
);
NormalizedBranch { condition, result }
}],
needs_data: BTreeSet::from([age_path]),
source: test_source(),
rule_type: Arc::new(primitive_boolean().clone()),
};
plan.rules.push(rule);
let deserialized = roundtrip_execution_plan(&plan);
let deserialized2 = roundtrip_execution_plan(&deserialized);
assert_eq!(deserialized2.spec_name, plan.spec_name);
assert_eq!(deserialized2.data.len(), plan.data.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()
);
}
fn empty_plan(effective: crate::parsing::ast::EffectiveDate) -> ExecutionPlan {
ExecutionPlan {
spec_name: "s".into(),
commentary: None,
data: IndexMap::new(),
rules: Vec::new(),
reference_evaluation_order: Vec::new(),
meta: HashMap::new(),
resolved_types: ResolvedSpecTypes::default(),
signature_index: HashMap::new(),
effective,
sources: Vec::new(),
}
}
#[test]
fn plan_at_exact_boundary_selects_later_slice() {
use crate::parsing::ast::{DateTimeValue, EffectiveDate};
let june = DateTimeValue {
year: 2025,
month: 6,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let dec = DateTimeValue {
year: 2025,
month: 12,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let set = ExecutionPlanSet {
spec_name: "s".into(),
plans: vec![
empty_plan(EffectiveDate::Origin),
empty_plan(EffectiveDate::DateTimeValue(june.clone())),
empty_plan(EffectiveDate::DateTimeValue(dec.clone())),
],
};
assert!(std::ptr::eq(
set.plan_at(&EffectiveDate::DateTimeValue(june.clone()))
.expect("boundary instant"),
&set.plans[1]
));
assert!(std::ptr::eq(
set.plan_at(&EffectiveDate::DateTimeValue(dec.clone()))
.expect("dec boundary"),
&set.plans[2]
));
}
#[test]
fn plan_at_day_before_boundary_stays_in_earlier_slice() {
use crate::parsing::ast::{DateTimeValue, EffectiveDate};
let june = DateTimeValue {
year: 2025,
month: 6,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let may_end = DateTimeValue {
year: 2025,
month: 5,
day: 31,
hour: 23,
minute: 59,
second: 59,
microsecond: 0,
timezone: None,
};
let set = ExecutionPlanSet {
spec_name: "s".into(),
plans: vec![
empty_plan(EffectiveDate::Origin),
empty_plan(EffectiveDate::DateTimeValue(june)),
],
};
assert!(std::ptr::eq(
set.plan_at(&EffectiveDate::DateTimeValue(may_end))
.expect("may 31"),
&set.plans[0]
));
}
#[test]
fn plan_at_single_plan_matches_any_instant_after_start() {
use crate::parsing::ast::{DateTimeValue, EffectiveDate};
let t = DateTimeValue {
year: 2025,
month: 3,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
};
let set = ExecutionPlanSet {
spec_name: "s".into(),
plans: vec![empty_plan(EffectiveDate::DateTimeValue(DateTimeValue {
year: 2025,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
timezone: None,
}))],
};
assert!(std::ptr::eq(
set.plan_at(&EffectiveDate::DateTimeValue(t))
.expect("inside single slice"),
&set.plans[0]
));
}
#[test]
fn schema_json_shape_contract() {
let mut engine = Engine::new();
engine
.load(
r#"
spec pricing
data bridge_height: quantity
-> unit meter 1
-> default 100 meter
data quantity: number -> minimum 0
rule cost: bridge_height * quantity
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"test.lemma",
))),
)
.unwrap();
let now = DateTimeValue::now();
let schema = engine
.get_plan(None, "pricing", Some(&now))
.unwrap()
.schema();
let value: serde_json::Value = serde_json::to_value(&schema).unwrap();
let bh = &value["data"]["bridge_height"];
assert!(
bh.is_object(),
"data entry must be a named object, not tuple"
);
assert!(
bh.get("type").is_some(),
"data entry must expose `type` field"
);
assert!(
bh.get("default").is_some(),
"bridge_height exposes `-> default` as schema default suggestion"
);
assert!(
bh.get("bound_value").is_none(),
"bridge_height is not a spec-bound literal"
);
let ty = &bh["type"];
assert_eq!(
ty["kind"], "quantity",
"kind tag sits on the type object itself"
);
assert!(
ty["units"].is_array(),
"quantity-only fields flatten up to top level"
);
assert!(
ty.get("options").is_none(),
"text-only fields must not leak"
);
let qty = &value["data"]["quantity"];
assert_eq!(qty["type"]["kind"], "number");
assert!(
qty.get("default").is_none(),
"quantity has no default suggestion"
);
assert!(
qty.get("bound_value").is_none(),
"quantity has no bound literal"
);
let cost = &value["rules"]["cost"];
assert_eq!(
cost["kind"], "quantity",
"rule types use the same flat shape"
);
assert!(
cost["units"].is_array() && !cost["units"].as_array().unwrap().is_empty(),
"quantity rule result types expose declared units"
);
assert!(
cost["units"][0].get("factor").is_some(),
"quantity rule units use factor field"
);
}
#[test]
fn schema_rule_result_units_contract() {
let mut engine = Engine::new();
engine
.load(
r#"
spec units_contract
data money: quantity
-> unit eur 1
-> unit usd 0.91
data rate: ratio
-> unit basis_points 10000
-> unit percent 100
-> default 500 basis_points
rule total: money
rule rate_out: rate
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"units_contract.lemma",
))),
)
.unwrap();
let now = DateTimeValue::now();
let schema = engine
.get_plan(None, "units_contract", Some(&now))
.unwrap()
.schema();
let value: serde_json::Value = serde_json::to_value(&schema).unwrap();
let money_units = &value["data"]["money"]["type"]["units"];
assert!(money_units.is_array() && !money_units.as_array().unwrap().is_empty());
assert!(money_units[0].get("name").is_some());
assert!(money_units[0].get("factor").is_some());
assert!(money_units[0]["factor"].get("numer").is_some());
assert!(money_units[0]["factor"].get("denom").is_some());
let rate_units = &value["data"]["rate"]["type"]["units"];
assert!(rate_units.is_array() && !rate_units.as_array().unwrap().is_empty());
assert!(rate_units[0].get("name").is_some());
assert!(rate_units[0].get("value").is_some());
assert!(rate_units[0]["value"].get("numer").is_some());
assert!(rate_units[0]["value"].get("denom").is_some());
let total_rule_units = &value["rules"]["total"]["units"];
let money_unit_names: Vec<_> = money_units
.as_array()
.unwrap()
.iter()
.map(|u| u["name"].as_str().unwrap())
.collect();
let total_rule_unit_names: Vec<_> = total_rule_units
.as_array()
.unwrap()
.iter()
.map(|u| u["name"].as_str().unwrap())
.collect();
assert_eq!(total_rule_unit_names, money_unit_names);
let rate_out_rule_units = &value["rules"]["rate_out"]["units"];
let rate_unit_names: Vec<_> = rate_units
.as_array()
.unwrap()
.iter()
.map(|u| u["name"].as_str().unwrap())
.collect();
let rate_out_rule_unit_names: Vec<_> = rate_out_rule_units
.as_array()
.unwrap()
.iter()
.map(|u| u["name"].as_str().unwrap())
.collect();
assert_eq!(rate_out_rule_unit_names, rate_unit_names);
}
#[test]
fn schema_json_round_trip_preserves_shape() {
let mut engine = Engine::new();
engine
.load(
r#"
spec s
data age: number -> minimum 0 -> default 18
data grade: text -> options "A" "B" "C"
rule adult: age >= 18
"#,
crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("s.lemma"))),
)
.unwrap();
let now = DateTimeValue::now();
let schema = engine.get_plan(None, "s", Some(&now)).unwrap().schema();
let json = serde_json::to_string(&schema).unwrap();
let round_tripped: SpecSchema = serde_json::from_str(&json).unwrap();
assert_eq!(schema, round_tripped);
}
}