1use 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
16pub 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}