axioval_rules/
property_comparison.rs

1//! Exact, source-neutral property-to-property comparison capability.
2
3use std::cmp::Ordering;
4
5use axioval_engine::{
6    CapabilityEvaluation, CompiledRule, NotEvaluatedReason, ParameterDescriptor, ParameterType,
7    PropertyRequest, PropertyResolution, PropertyResolutionServiceHandle, RelationshipQuery,
8    RelationshipSelectionError, RelationshipSelectionRequest, RelationshipSelectionServiceHandle,
9    RuleCapability, RuleContext, SemanticRelationship, TraversalDirection,
10};
11use axioval_ir::contract::{ParameterValue, Selector};
12use axioval_ir::{Evidence, Finding, Object, PropertyValue, Severity};
13
14use crate::selection::{property_error, select_objects};
15
16/// Compares a property on relationship-selected candidates with a property on each checked object.
17pub struct PropertyComparison;
18
19#[derive(Clone, Copy)]
20enum Mode {
21    Checked,
22    Shared,
23    Related,
24}
25#[derive(Clone, Copy)]
26enum Quantifier {
27    Each,
28    AtLeastOne,
29}
30#[derive(Clone, Copy)]
31enum Operator {
32    Equals,
33    NotEquals,
34    Greater,
35    GreaterOrEqual,
36    Less,
37    LessOrEqual,
38    Contains,
39}
40
41impl RuleCapability for PropertyComparison {
42    fn id(&self) -> &'static str {
43        "axioval:capability.property-comparison"
44    }
45    fn parameters(&self) -> Vec<ParameterDescriptor> {
46        vec![
47            ParameterDescriptor::required("compared_selector", ParameterType::Selector),
48            ParameterDescriptor::required("compared_property", ParameterType::PropertyReference),
49            ParameterDescriptor::required("target_property", ParameterType::PropertyReference),
50            ParameterDescriptor::required("operator", ParameterType::String),
51            ParameterDescriptor::required("factor", ParameterType::Number),
52            ParameterDescriptor::required("component_mode", ParameterType::String),
53            ParameterDescriptor::optional("relationship", ParameterType::String),
54            ParameterDescriptor::optional("direction", ParameterType::String),
55            ParameterDescriptor::optional("follow_chain", ParameterType::Boolean),
56            ParameterDescriptor::required("quantifier", ParameterType::String),
57        ]
58    }
59    #[allow(clippy::too_many_lines)]
60    fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation {
61        let Some(config) = Config::parse(rule) else {
62            return CapabilityEvaluation::not_evaluated(
63                NotEvaluatedReason::InvalidDeclaration,
64                "property-comparison parameters are invalid",
65            );
66        };
67        let (checked, mut evaluation) = select_objects(context, &rule.selector);
68        let (universe, universe_outcomes) = select_objects(context, config.selector);
69        if !universe_outcomes.not_evaluated_outcomes().is_empty() {
70            for object in checked {
71                evaluation.push_object_not_evaluated(
72                    object.id.clone(),
73                    NotEvaluatedReason::InvalidEvidence,
74                    "compared selector was not evaluated conclusively",
75                );
76            }
77            return evaluation;
78        }
79        for object in checked {
80            let selected = match config.mode {
81                Mode::Checked => Ok((
82                    if universe
83                        .binary_search_by_key(&&object.id, |candidate| &candidate.id)
84                        .is_ok()
85                    {
86                        vec![object.id.clone()]
87                    } else {
88                        vec![]
89                    },
90                    vec![],
91                )),
92                Mode::Shared | Mode::Related => {
93                    relationship_selection(context, object, &universe, &config)
94                }
95            };
96            let (candidates, relation_evidence) = match selected {
97                Ok(value) => value,
98                Err((reason, message)) => {
99                    evaluation.push_object_not_evaluated(object.id.clone(), reason, message);
100                    continue;
101                }
102            };
103            if candidates.is_empty() {
104                if matches!(config.quantifier, Quantifier::AtLeastOne) {
105                    evaluation.push_finding(make_finding(
106                        rule,
107                        object,
108                        "no candidate satisfies comparison".into(),
109                        relation_evidence,
110                    ));
111                }
112                continue;
113            }
114            let Some(properties) = context.services.get::<PropertyResolutionServiceHandle>() else {
115                evaluation.push_object_not_evaluated(
116                    object.id.clone(),
117                    NotEvaluatedReason::MissingService,
118                    "property-resolution service is not registered",
119                );
120                continue;
121            };
122            let target = resolve(properties, object, config.target_set, config.target_name);
123            let target = match target {
124                Ok((Some(value), _)) => value,
125                Ok((None, absence_evidence)) => {
126                    evaluation.push_finding(make_finding(
127                        rule,
128                        object,
129                        "target property is absent".into(),
130                        combined(&relation_evidence, &absence_evidence, &[]),
131                    ));
132                    continue;
133                }
134                Err((reason, message)) => {
135                    evaluation.push_object_not_evaluated(object.id.clone(), reason, message);
136                    continue;
137                }
138            };
139            let mut any_match = false;
140            let mut uncertainties = Vec::new();
141            let mut mismatches = Vec::new();
142            let mut missing = Vec::new();
143            for candidate_id in candidates {
144                let Some(candidate) = context.project.object(&candidate_id) else {
145                    uncertainties.push((
146                        NotEvaluatedReason::InvalidEvidence,
147                        "relationship candidate is absent from project".into(),
148                    ));
149                    continue;
150                };
151                match resolve(
152                    properties,
153                    candidate,
154                    config.compared_set,
155                    config.compared_name,
156                ) {
157                    Ok((Some(compared), _)) => match compare(
158                        &compared.0.value,
159                        &target.0.value,
160                        config.factor,
161                        config.operator,
162                    ) {
163                        Ok(true) => any_match = true,
164                        Ok(false) => mismatches.push((
165                            candidate,
166                            combined(&relation_evidence, &compared.1, &target.1),
167                        )),
168                        Err(message) => {
169                            uncertainties.push((NotEvaluatedReason::InvalidEvidence, message));
170                        }
171                    },
172                    Ok((None, absence_evidence)) => missing.push((
173                        candidate,
174                        combined(&relation_evidence, &absence_evidence, &target.1),
175                    )),
176                    Err(error) => uncertainties.push(error),
177                }
178            }
179            let has_missing_information = !missing.is_empty();
180            for (candidate, evidence) in missing {
181                evaluation.push_finding(make_finding(
182                    rule,
183                    candidate,
184                    "compared property is absent".into(),
185                    evidence,
186                ));
187            }
188            match config.quantifier {
189                Quantifier::Each => {
190                    for (candidate, evidence) in mismatches {
191                        evaluation.push_finding(make_finding(
192                            rule,
193                            object,
194                            format!("candidate {} does not satisfy comparison", candidate.id),
195                            evidence,
196                        ));
197                    }
198                    for (reason, message) in uncertainties {
199                        evaluation.push_object_not_evaluated(object.id.clone(), reason, message);
200                    }
201                }
202                Quantifier::AtLeastOne if any_match || has_missing_information => {}
203                Quantifier::AtLeastOne => {
204                    if uncertainties.is_empty() {
205                        let mut evidence = combined(&relation_evidence, &target.1, &[]);
206                        for (_, mismatch_evidence) in &mismatches {
207                            evidence = combined(&evidence, mismatch_evidence, &[]);
208                        }
209                        evaluation.push_finding(make_finding(
210                            rule,
211                            object,
212                            "no candidate satisfies comparison".into(),
213                            evidence,
214                        ));
215                    } else {
216                        for (reason, message) in uncertainties {
217                            evaluation.push_object_not_evaluated(
218                                object.id.clone(),
219                                reason,
220                                message,
221                            );
222                        }
223                    }
224                }
225            }
226        }
227        evaluation
228    }
229}
230
231struct Config<'a> {
232    selector: &'a Selector,
233    compared_set: Option<&'a str>,
234    compared_name: &'a str,
235    target_set: Option<&'a str>,
236    target_name: &'a str,
237    operator: Operator,
238    factor: f64,
239    mode: Mode,
240    relationship: Option<&'a str>,
241    direction: TraversalDirection,
242    follow_chain: bool,
243    quantifier: Quantifier,
244}
245impl<'a> Config<'a> {
246    fn parse(rule: &'a CompiledRule) -> Option<Self> {
247        let ParameterValue::Selector { value: selector } =
248            rule.parameters.get("compared_selector")?
249        else {
250            return None;
251        };
252        let property = |name| match rule.parameters.get(name)? {
253            ParameterValue::PropertyReference {
254                property,
255                property_set,
256            } => Some((property_set.as_deref(), property.as_str())),
257            _ => None,
258        };
259        let (compared_set, compared_name) = property("compared_property")?;
260        let (target_set, target_name) = property("target_property")?;
261        let string = |name| match rule.parameters.get(name)? {
262            ParameterValue::String { value } => Some(value.as_str()),
263            _ => None,
264        };
265        let operator = match string("operator")? {
266            "equals" => Operator::Equals,
267            "not_equals" => Operator::NotEquals,
268            "greater" => Operator::Greater,
269            "greater_or_equal" => Operator::GreaterOrEqual,
270            "less" => Operator::Less,
271            "less_or_equal" => Operator::LessOrEqual,
272            "contains" => Operator::Contains,
273            _ => return None,
274        };
275        let factor = match rule.parameters.get("factor")? {
276            ParameterValue::Number { value } if value.is_finite() => *value,
277            _ => return None,
278        };
279        let mode = match string("component_mode")? {
280            "checked" => Mode::Checked,
281            "shared" => Mode::Shared,
282            "related" => Mode::Related,
283            _ => return None,
284        };
285        let relationship = string("relationship");
286        if !matches!(mode, Mode::Checked) && relationship.is_none_or(str::is_empty) {
287            return None;
288        }
289        let direction = match string("direction") {
290            None | Some("forward") => TraversalDirection::Forward,
291            Some("backward") => TraversalDirection::Backward,
292            Some("either") => TraversalDirection::Either,
293            Some(_) => return None,
294        };
295        let follow_chain = match rule.parameters.get("follow_chain") {
296            None => false,
297            Some(ParameterValue::Boolean { value }) => *value,
298            _ => return None,
299        };
300        let quantifier = match string("quantifier")? {
301            "each" => Quantifier::Each,
302            "at_least_one" => Quantifier::AtLeastOne,
303            _ => return None,
304        };
305        Some(Self {
306            selector,
307            compared_set,
308            compared_name,
309            target_set,
310            target_name,
311            operator,
312            factor,
313            mode,
314            relationship,
315            direction,
316            follow_chain,
317            quantifier,
318        })
319    }
320}
321
322type Resolved = (axioval_ir::Property, Vec<Evidence>);
323fn resolve(
324    service: &PropertyResolutionServiceHandle,
325    object: &Object,
326    set: Option<&str>,
327    name: &str,
328) -> Result<(Option<Resolved>, Vec<Evidence>), (NotEvaluatedReason, String)> {
329    let request = PropertyRequest::try_new(object.id.clone(), set.map(str::to_owned), name)
330        .map_err(|error| (NotEvaluatedReason::InvalidDeclaration, error.to_string()))?;
331    match service.resolve(&request) {
332        Ok(PropertyResolution::Present(value)) => {
333            let property = value.property().clone();
334            Ok((
335                Some((property.clone(), property.evidence.into_iter().collect())),
336                Vec::new(),
337            ))
338        }
339        Ok(PropertyResolution::Absent(proof)) => Ok((None, vec![proof.evidence().clone()])),
340        Err(error) => Err(property_error(error)),
341    }
342}
343fn relationship_selection(
344    context: &RuleContext<'_>,
345    object: &Object,
346    universe: &[&Object],
347    config: &Config<'_>,
348) -> Result<(Vec<axioval_ir::ObjectId>, Vec<Evidence>), (NotEvaluatedReason, String)> {
349    let Some(service) = context.services.get::<RelationshipSelectionServiceHandle>() else {
350        return Err((
351            NotEvaluatedReason::MissingService,
352            "relationship-selection service is not registered".into(),
353        ));
354    };
355    let relationship = SemanticRelationship::try_new(config.relationship.unwrap_or_default())
356        .map_err(|error| (NotEvaluatedReason::InvalidDeclaration, error.to_string()))?;
357    let query = match config.mode {
358        Mode::Shared => RelationshipQuery::SharedGroup { relationship },
359        Mode::Related => RelationshipQuery::Related {
360            relationship,
361            direction: config.direction,
362            follow_chain: config.follow_chain,
363        },
364        Mode::Checked => unreachable!(),
365    };
366    let request = RelationshipSelectionRequest::try_new(
367        object.id.clone(),
368        universe.iter().map(|item| item.id.clone()).collect(),
369        query,
370    )
371    .map_err(|error| (NotEvaluatedReason::InvalidDeclaration, error.to_string()))?;
372    service
373        .select(&request)
374        .map(|selection| {
375            (
376                selection.candidates().to_vec(),
377                selection.evidence().to_vec(),
378            )
379        })
380        .map_err(|error| match error {
381            RelationshipSelectionError::Unavailable(message) => {
382                (NotEvaluatedReason::BackendUnavailable, message)
383            }
384            other => (NotEvaluatedReason::InvalidEvidence, other.to_string()),
385        })
386}
387fn compare(
388    left: &PropertyValue,
389    right: &PropertyValue,
390    factor: f64,
391    operator: Operator,
392) -> Result<bool, String> {
393    let equal = |ord: Ordering| match operator {
394        Operator::Equals => ord.is_eq(),
395        Operator::NotEquals => !ord.is_eq(),
396        Operator::Greater => ord.is_gt(),
397        Operator::GreaterOrEqual => ord.is_ge(),
398        Operator::Less => ord.is_lt(),
399        Operator::LessOrEqual => ord.is_le(),
400        Operator::Contains => false,
401    };
402    match (left, right) {
403        (PropertyValue::Boolean(a), PropertyValue::Boolean(b)) if exact_one(factor) => {
404            match operator {
405                Operator::Equals => Ok(a == b),
406                Operator::NotEquals => Ok(a != b),
407                _ => Err("boolean comparison operator is invalid".into()),
408            }
409        }
410        (PropertyValue::String(a), PropertyValue::String(b)) if exact_one(factor) => match operator
411        {
412            Operator::Equals => Ok(a == b),
413            Operator::NotEquals => Ok(a != b),
414            Operator::Contains => Ok(a.contains(b)),
415            _ => Err("string comparison operator is invalid".into()),
416        },
417        (PropertyValue::Integer(a), PropertyValue::Integer(b)) if exact_one(factor) => {
418            Ok(equal(a.cmp(b)))
419        }
420        (
421            PropertyValue::Quantity {
422                value: a,
423                dimension: da,
424            },
425            PropertyValue::Quantity {
426                value: b,
427                dimension: db,
428            },
429        ) if da == db => {
430            if a.is_finite() && b.is_finite() {
431                numeric(*a, *b, factor, equal)
432            } else {
433                Err("quantity value is non-finite".into())
434            }
435        }
436        (PropertyValue::Integer(a), PropertyValue::Decimal(b))
437            if (*a).unsigned_abs() <= (1_u64 << 53) && b.is_finite() =>
438        {
439            numeric(integer_to_f64(*a)?, *b, factor, equal)
440        }
441        (PropertyValue::Decimal(a), PropertyValue::Integer(b))
442            if (*b).unsigned_abs() <= (1_u64 << 53) && a.is_finite() =>
443        {
444            numeric(*a, integer_to_f64(*b)?, factor, equal)
445        }
446        (PropertyValue::Decimal(a), PropertyValue::Decimal(b))
447            if a.is_finite() && b.is_finite() =>
448        {
449            numeric(*a, *b, factor, equal)
450        }
451        (PropertyValue::Integer(_), PropertyValue::Integer(_)) => {
452            Err("integer factor cannot be represented exactly".into())
453        }
454        _ => Err("property values have incompatible types or dimensions".into()),
455    }
456}
457fn numeric(
458    left: f64,
459    right: f64,
460    factor: f64,
461    predicate: impl FnOnce(Ordering) -> bool,
462) -> Result<bool, String> {
463    let scaled = right * factor;
464    if scaled.is_finite() {
465        Ok(predicate(left.total_cmp(&scaled)))
466    } else {
467        Err("scaled target is non-finite".into())
468    }
469}
470fn exact_one(value: f64) -> bool {
471    value.to_bits() == 1.0_f64.to_bits()
472}
473fn integer_to_f64(value: i64) -> Result<f64, String> {
474    if value.unsigned_abs() > (1_u64 << 53) {
475        return Err("integer cannot be represented exactly as a decimal".into());
476    }
477    #[allow(clippy::cast_precision_loss)]
478    Ok(value as f64)
479}
480fn combined(parts: &[Evidence], left: &[Evidence], right: &[Evidence]) -> Vec<Evidence> {
481    let mut values = parts
482        .iter()
483        .chain(left)
484        .chain(right)
485        .cloned()
486        .collect::<Vec<_>>();
487    values.sort_by(|a, b| (&a.source, &a.locator).cmp(&(&b.source, &b.locator)));
488    values.dedup();
489    values
490}
491fn make_finding(
492    rule: &CompiledRule,
493    object: &Object,
494    message: String,
495    evidence: Vec<Evidence>,
496) -> Finding {
497    Finding {
498        rule_id: rule.id.clone(),
499        object_id: object.id.clone(),
500        severity: match rule.severity {
501            axioval_ir::contract::Severity::Error => Severity::Error,
502            axioval_ir::contract::Severity::Warning => Severity::Warning,
503            axioval_ir::contract::Severity::Info => Severity::Info,
504        },
505        message,
506        evidence,
507    }
508}