Skip to main content

axioval_engine/
rule_outcomes.rs

1//! Rules that depend on other rules' outcomes: gates and `ruleOutcome`
2//! selectors, the order they impose on a plan, and the outcomes the runtime
3//! records for them.
4//!
5//! Capabilities never feed one another. A rule instance may instead read
6//! how another rule of its ruleset fared, as a whole (a gate that runs it
7//! only if the other rule passed or failed) or per object (a selector on
8//! the objects the other rule passed or failed). The runtime runs the other
9//! rule first, records its refined outcomes, and answers from them. What
10//! the other rule left undecided stays undecided: never a match, never a
11//! non-match.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::Arc;
15
16use axioval_ir::contract::{GateCondition, ParameterValue, RuleOutcomeKind, Selector, TableRow};
17use axioval_ir::{Evidence, NotEvaluatedReason, Object, ObjectId, RuleId, Scope, SourceId};
18
19use crate::{CapabilityEvaluation, CompiledRule, OutcomeRefiner, ResourceObjects, RuleContext};
20
21/// How a selector judged one object, as the host's outcome refiner
22/// evaluates it ([`crate::OutcomeRefiner::evaluate_selector`]).
23#[derive(Clone, Debug, PartialEq)]
24pub enum SelectorVerdict {
25    /// Selected, with the facts that decided it.
26    Match(Vec<Evidence>),
27    /// Not selected, with the facts that decided it.
28    NoMatch(Vec<Evidence>),
29    /// Cannot be decided, and why.
30    Undecided(NotEvaluatedReason, String),
31}
32
33/// How a completed rule fared as a whole.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum RuleVerdict {
36    /// No finding and nothing left not evaluated.
37    Passed,
38    /// At least one finding.
39    Failed,
40    /// No finding, but something left not evaluated.
41    Undecided,
42    /// Its own gate skipped it.
43    Skipped,
44}
45
46/// How a completed rule judged one object.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub enum ObjectVerdict {
49    /// Selected, and nothing found or left open about it.
50    Passed,
51    /// The subject of at least one finding.
52    Failed,
53    /// Surely not selected, and nothing reported about it.
54    NotSelected,
55    /// Left not evaluated, or its selection undecided, and why.
56    Undecided(String),
57}
58
59/// One completed rule's refined outcomes, as dependent rules read them.
60#[derive(Clone, Debug, Default)]
61pub struct RuleRecord {
62    skipped: bool,
63    found: bool,
64    open: bool,
65    /// Subjects of findings.
66    failed: BTreeSet<ObjectId>,
67    /// Objects left not evaluated, with the first reason.
68    undecided: BTreeMap<ObjectId, String>,
69    /// Sources reported about as a whole, with the first message.
70    sources: BTreeMap<SourceId, String>,
71    /// The first outcome about the project as a whole.
72    project: Option<String>,
73    /// The rule's selection, when a dependent reads it per object: every
74    /// object surely selected, and every object whose selection is
75    /// undecided with why.
76    selection: Option<(BTreeSet<ObjectId>, BTreeMap<ObjectId, String>)>,
77    /// Sources where the rule's selection reached resource objects that
78    /// could not be listed, with why.
79    unread: BTreeSet<(SourceId, String)>,
80}
81
82impl RuleRecord {
83    /// The record of a rule its gate skipped.
84    pub(crate) fn skipped() -> Self {
85        Self {
86            skipped: true,
87            ..Self::default()
88        }
89    }
90
91    /// The record of a rule left not evaluated as a whole.
92    pub(crate) fn undecided(message: &str) -> Self {
93        Self {
94            open: true,
95            project: Some(message.to_owned()),
96            ..Self::default()
97        }
98    }
99
100    /// The record of a rule's refined `evaluation`, with its applicability
101    /// selection when a dependent reads it per object.
102    pub(crate) fn of(evaluation: &CapabilityEvaluation, selection: Option<Selection>) -> Self {
103        let mut record = Self {
104            found: !evaluation.findings().is_empty(),
105            open: !evaluation.not_evaluated_outcomes().is_empty(),
106            ..Self::default()
107        };
108        for finding in evaluation.findings() {
109            let message = format!("it reported `{}` about it", finding.message);
110            match &finding.scope {
111                Scope::Object(object) => {
112                    record.failed.insert(object.clone());
113                }
114                scope => record.note_whole(scope, message),
115            }
116        }
117        for outcome in evaluation.not_evaluated_outcomes() {
118            match outcome.scope() {
119                Scope::Object(object) => {
120                    record
121                        .undecided
122                        .entry(object.clone())
123                        .or_insert_with(|| outcome.message().to_owned());
124                }
125                scope => record.note_whole(scope, outcome.message().to_owned()),
126            }
127        }
128        let selection = selection.map(|selection| {
129            record.unread = selection.unread.into_iter().collect();
130            selection.verdicts
131        });
132        record.selection = selection.map(|verdicts| {
133            let mut selected = BTreeSet::new();
134            let mut open = BTreeMap::new();
135            for (object, verdict) in verdicts {
136                match verdict {
137                    SelectorVerdict::Match(_) => {
138                        selected.insert(object);
139                    }
140                    SelectorVerdict::NoMatch(_) => {}
141                    SelectorVerdict::Undecided(_, why) => {
142                        open.insert(object, why);
143                    }
144                }
145            }
146            (selected, open)
147        });
148        record
149    }
150
151    fn note_whole(&mut self, scope: &Scope, message: String) {
152        match scope {
153            Scope::Source(source) => {
154                self.sources.entry(source.clone()).or_insert(message);
155            }
156            _ => {
157                self.project.get_or_insert(message);
158            }
159        }
160    }
161
162    /// Every object and resource object the rule's outcomes or recorded
163    /// selection name, sorted, possibly more than once.
164    pub fn named(&self) -> impl Iterator<Item = &ObjectId> {
165        let (selected, open) = self
166            .selection
167            .as_ref()
168            .map(|(selected, open)| (Some(selected), Some(open)))
169            .unwrap_or_default();
170        self.failed
171            .iter()
172            .chain(self.undecided.keys())
173            .chain(selected.into_iter().flatten())
174            .chain(open.into_iter().flat_map(BTreeMap::keys))
175    }
176
177    /// Sources where the rule's selection reached resource objects that
178    /// could not be listed, with why.
179    pub fn unread_resources(&self) -> impl Iterator<Item = &(SourceId, String)> {
180        self.unread.iter()
181    }
182
183    /// How the rule fared as a whole.
184    #[must_use]
185    pub fn verdict(&self) -> RuleVerdict {
186        if self.skipped {
187            RuleVerdict::Skipped
188        } else if self.found {
189            RuleVerdict::Failed
190        } else if self.open {
191            RuleVerdict::Undecided
192        } else {
193            RuleVerdict::Passed
194        }
195    }
196
197    /// How the rule judged `object`.
198    ///
199    /// A finding about the object fails it. Otherwise an object left not
200    /// evaluated, or whose source or project the rule reported about as a
201    /// whole, or whose selection the rule could not decide, is undecided;
202    /// an object the rule surely did not select is not selected, and one it
203    /// selected passed. A skipped rule selected nothing. Without a recorded
204    /// selection every object is undecided.
205    #[must_use]
206    pub fn object(&self, object: &Object) -> ObjectVerdict {
207        if self.failed.contains(&object.id) {
208            return ObjectVerdict::Failed;
209        }
210        if self.skipped {
211            return ObjectVerdict::NotSelected;
212        }
213        if let Some(why) = self.undecided.get(&object.id) {
214            return ObjectVerdict::Undecided(format!("it was not evaluated: {why}"));
215        }
216        let Some((selected, open)) = &self.selection else {
217            return ObjectVerdict::Undecided("its selection was not recorded".into());
218        };
219        let chosen = selected.contains(&object.id);
220        let doubt = open.get(&object.id);
221        if !chosen && doubt.is_none() {
222            return ObjectVerdict::NotSelected;
223        }
224        if let Some(why) = &self.project {
225            return ObjectVerdict::Undecided(format!("it reported about the whole model: {why}"));
226        }
227        if let Some(why) = self.sources.get(&object.id.source) {
228            return ObjectVerdict::Undecided(format!(
229                "it reported about source `{}` as a whole: {why}",
230                object.id.source
231            ));
232        }
233        match doubt {
234            Some(why) => ObjectVerdict::Undecided(format!("its selection is undecided: {why}")),
235            None => ObjectVerdict::Passed,
236        }
237    }
238}
239
240/// The outcomes of every rule completed so far in a run, by compiled rule
241/// id. The runtime installs it before every rule, replacing any host copy,
242/// so a `ruleOutcome` selector reads the rules the plan ran first.
243#[derive(Clone, Debug, Default)]
244pub struct RuleOutcomes(BTreeMap<RuleId, Arc<RuleRecord>>);
245
246impl RuleOutcomes {
247    /// The record of the completed rule `rule`, if it ran before.
248    #[must_use]
249    pub fn get(&self, rule: &str) -> Option<&RuleRecord> {
250        let id = RuleId::new(rule).ok()?;
251        self.0.get(&id).map(AsRef::as_ref)
252    }
253
254    pub(crate) fn insert(&mut self, rule: RuleId, record: RuleRecord) {
255        self.0.insert(rule, Arc::new(record));
256    }
257
258    /// Whether whole-rule `gates` let their rule run: closed when any is,
259    /// else undecided when any is.
260    pub(crate) fn gate(&self, gates: &[(RuleId, GateCondition)]) -> Gate {
261        let mut state = Gate::Open;
262        for (parent, condition) in gates {
263            let verdict = self
264                .0
265                .get(parent)
266                .map_or(RuleVerdict::Undecided, |record| record.verdict());
267            match gate(parent, *condition, verdict) {
268                Gate::Closed => return Gate::Closed,
269                undecided @ Gate::Undecided(_) if state == Gate::Open => state = undecided,
270                _ => {}
271            }
272        }
273        state
274    }
275
276    /// Whether `rule` judged `object` as `outcome` asks, for a
277    /// `ruleOutcome` selector; `Err` with a reason when that cannot be
278    /// decided.
279    pub fn selects(
280        &self,
281        rule: &str,
282        outcome: RuleOutcomeKind,
283        object: &Object,
284    ) -> Result<bool, (NotEvaluatedReason, String)> {
285        let Some(record) = self.get(rule) else {
286            return Err((
287                NotEvaluatedReason::InvalidDeclaration,
288                format!("the outcomes of rule `{rule}` are not available: it has not run before"),
289            ));
290        };
291        match record.object(object) {
292            ObjectVerdict::Passed => Ok(outcome == RuleOutcomeKind::Passed),
293            ObjectVerdict::Failed => Ok(outcome == RuleOutcomeKind::Failed),
294            ObjectVerdict::NotSelected => Ok(false),
295            ObjectVerdict::Undecided(why) => Err((
296                NotEvaluatedReason::IncompleteEvidence,
297                format!("rule `{rule}` left {} undecided: {why}", object.id),
298            )),
299        }
300    }
301}
302
303/// How a rule's applicability selector judged its population.
304pub(crate) struct Selection {
305    verdicts: Vec<(ObjectId, SelectorVerdict)>,
306    unread: Vec<(SourceId, String)>,
307}
308
309impl From<Vec<(ObjectId, SelectorVerdict)>> for Selection {
310    /// A selection that reached no unlisted resource objects.
311    fn from(verdicts: Vec<(ObjectId, SelectorVerdict)>) -> Self {
312        Self {
313            verdicts,
314            unread: Vec::new(),
315        }
316    }
317}
318
319/// How `rule`'s applicability selector judges every object of the
320/// project and every resource object it reaches, as a dependent reading it
321/// per object needs.
322pub(crate) fn selection(
323    refiner: &dyn OutcomeRefiner,
324    context: &RuleContext<'_>,
325    rule: &CompiledRule,
326) -> Selection {
327    let reached = context
328        .services
329        .get::<ResourceObjects>()
330        .map(|resources| resources.reached(&rule.selector, context.services.get::<RuleOutcomes>()))
331        .unwrap_or_default();
332    let verdicts = context
333        .project
334        .objects()
335        .chain(reached.objects)
336        .map(|object| {
337            let verdict = refiner.evaluate_selector(context, &rule.selector, object);
338            (object.id.clone(), verdict)
339        })
340        .collect();
341    Selection {
342        verdicts,
343        unread: reached.unreadable,
344    }
345}
346
347/// Whether a whole-rule gate lets its rule run.
348#[derive(Clone, Debug, Eq, PartialEq)]
349pub(crate) enum Gate {
350    Open,
351    Closed,
352    Undecided(String),
353}
354
355/// The state of the whole-rule gate `condition` on the rule `parent` whose
356/// verdict is `verdict`. A skipped parent keeps every gate closed.
357pub(crate) fn gate(parent: &RuleId, condition: GateCondition, verdict: RuleVerdict) -> Gate {
358    let wants = match condition {
359        GateCondition::AllIfPassed => RuleVerdict::Passed,
360        GateCondition::AllIfFailed => RuleVerdict::Failed,
361        // Object conditions narrow the selection; they never close a rule.
362        GateCondition::PassedObjects | GateCondition::FailedObjects => return Gate::Open,
363    };
364    match verdict {
365        RuleVerdict::Undecided => Gate::Undecided(format!(
366            "the rule runs only if rule `{parent}` {}, and `{parent}` left outcomes not \
367             evaluated without a finding, so whether it passed is undecided",
368            if wants == RuleVerdict::Passed {
369                "passed"
370            } else {
371                "failed"
372            }
373        )),
374        verdict if verdict == wants => Gate::Open,
375        _ => Gate::Closed,
376    }
377}
378
379/// The selector an object gate narrows its rule's applicability by.
380pub(crate) fn gate_selector(rule: &str, condition: GateCondition) -> Option<Selector> {
381    let outcome = match condition {
382        GateCondition::PassedObjects => RuleOutcomeKind::Passed,
383        GateCondition::FailedObjects => RuleOutcomeKind::Failed,
384        GateCondition::AllIfPassed | GateCondition::AllIfFailed => return None,
385    };
386    Some(Selector::RuleOutcome {
387        rule: rule.to_owned(),
388        outcome,
389    })
390}
391
392/// Every rule a `ruleOutcome` selector in `selector` names.
393pub(crate) fn selector_references<'a>(selector: &'a Selector, out: &mut BTreeSet<&'a str>) {
394    match selector {
395        Selector::RuleOutcome { rule, .. } => {
396            out.insert(rule);
397        }
398        Selector::AllOf { operands } | Selector::AnyOf { operands } => {
399            for operand in operands {
400                selector_references(operand, out);
401            }
402        }
403        Selector::Not { operand } => selector_references(operand, out),
404        Selector::Related { selector, .. } => selector_references(selector, out),
405        Selector::All
406        | Selector::EntityType { .. }
407        | Selector::Property { .. }
408        | Selector::PropertyPattern { .. }
409        | Selector::Classification { .. }
410        | Selector::Discipline { .. }
411        | Selector::Source { .. } => {}
412    }
413}
414
415/// Every rule a selector within `value` names.
416pub(crate) fn value_references<'a>(value: &'a ParameterValue, out: &mut BTreeSet<&'a str>) {
417    match value {
418        ParameterValue::Selector { value } => selector_references(value, out),
419        ParameterValue::Table { value: rows } => {
420            for cell in rows.iter().flat_map(TableRow::values) {
421                value_references(cell, out);
422            }
423        }
424        _ => {}
425    }
426}
427
428/// Renames every rule a `ruleOutcome` selector in `selector` names.
429pub(crate) fn rename_selector(selector: &mut Selector, rename: &dyn Fn(&str) -> String) {
430    match selector {
431        Selector::RuleOutcome { rule, .. } => *rule = rename(rule),
432        Selector::AllOf { operands } | Selector::AnyOf { operands } => {
433            for operand in operands {
434                rename_selector(operand, rename);
435            }
436        }
437        Selector::Not { operand } => rename_selector(operand, rename),
438        Selector::Related { selector, .. } => rename_selector(selector, rename),
439        Selector::All
440        | Selector::EntityType { .. }
441        | Selector::Property { .. }
442        | Selector::PropertyPattern { .. }
443        | Selector::Classification { .. }
444        | Selector::Discipline { .. }
445        | Selector::Source { .. } => {}
446    }
447}
448
449/// Renames every rule a selector within `value` names.
450pub(crate) fn rename_value(value: &mut ParameterValue, rename: &dyn Fn(&str) -> String) {
451    match value {
452        ParameterValue::Selector { value } => rename_selector(value, rename),
453        ParameterValue::Table { value: rows } => {
454            for cell in rows.iter_mut().flat_map(|row| row.values_mut()) {
455                rename_value(cell, rename);
456            }
457        }
458        _ => {}
459    }
460}
461
462/// `rules` ordered so every rule follows the rules it depends on, ties by
463/// id; `Err` with the rules of a cycle.
464pub(crate) fn dependency_order(
465    rules: Vec<CompiledRule>,
466    dependencies: &BTreeMap<RuleId, BTreeSet<RuleId>>,
467) -> Result<Vec<CompiledRule>, Vec<RuleId>> {
468    let mut pending: BTreeMap<RuleId, CompiledRule> = rules
469        .into_iter()
470        .map(|rule| (rule.id.clone(), rule))
471        .collect();
472    let mut ordered = Vec::with_capacity(pending.len());
473    loop {
474        let ready: Option<RuleId> = pending
475            .keys()
476            .find(|id| {
477                dependencies
478                    .get(*id)
479                    .is_none_or(|needs| needs.iter().all(|need| !pending.contains_key(need)))
480            })
481            .cloned();
482        match ready {
483            Some(id) => ordered.push(pending.remove(&id).expect("a ready rule is pending")),
484            None if pending.is_empty() => return Ok(ordered),
485            None => return Err(pending.into_keys().collect()),
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use axioval_ir::{Finding, Severity};
494
495    fn id(local: &str) -> ObjectId {
496        ObjectId::new(SourceId::new("test", "model").unwrap(), local).unwrap()
497    }
498
499    fn object(local: &str) -> Object {
500        Object::new(id(local), "door")
501    }
502
503    /// d1 selected and clean, d2 found, d3 left open, d4 not selected, d5
504    /// of undecided selection.
505    fn record() -> RuleRecord {
506        let mut evaluation = CapabilityEvaluation::default();
507        evaluation.push_finding(Finding::new(
508            RuleId::new("type").unwrap(),
509            id("d2"),
510            Severity::Error,
511            "no rating",
512        ));
513        evaluation.push_object_not_evaluated(
514            id("d3"),
515            NotEvaluatedReason::BackendUnavailable,
516            "unreadable",
517        );
518        let selection = vec![
519            (id("d1"), SelectorVerdict::Match(Vec::new())),
520            (id("d2"), SelectorVerdict::Match(Vec::new())),
521            (id("d3"), SelectorVerdict::Match(Vec::new())),
522            (id("d4"), SelectorVerdict::NoMatch(Vec::new())),
523            (
524                id("d5"),
525                SelectorVerdict::Undecided(NotEvaluatedReason::InvalidEvidence, "list".into()),
526            ),
527        ];
528        RuleRecord::of(&evaluation, Some(selection.into()))
529    }
530
531    #[test]
532    fn an_object_is_judged_by_its_finding_its_outcome_and_its_selection() {
533        let record = record();
534        assert_eq!(record.verdict(), RuleVerdict::Failed);
535        assert_eq!(record.object(&object("d1")), ObjectVerdict::Passed);
536        assert_eq!(record.object(&object("d2")), ObjectVerdict::Failed);
537        assert!(matches!(
538            record.object(&object("d3")),
539            ObjectVerdict::Undecided(_)
540        ));
541        assert_eq!(record.object(&object("d4")), ObjectVerdict::NotSelected);
542        assert!(matches!(
543            record.object(&object("d5")),
544            ObjectVerdict::Undecided(_)
545        ));
546    }
547
548    #[test]
549    fn an_outcome_about_the_source_leaves_its_selected_objects_undecided() {
550        let mut evaluation = CapabilityEvaluation::default();
551        evaluation.push_source_not_evaluated(
552            SourceId::new("test", "model").unwrap(),
553            NotEvaluatedReason::NotRecorded,
554            "no layers",
555        );
556        let record = RuleRecord::of(
557            &evaluation,
558            Some(
559                vec![
560                    (id("d1"), SelectorVerdict::Match(Vec::new())),
561                    (id("d4"), SelectorVerdict::NoMatch(Vec::new())),
562                ]
563                .into(),
564            ),
565        );
566        assert_eq!(record.verdict(), RuleVerdict::Undecided);
567        assert!(matches!(
568            record.object(&object("d1")),
569            ObjectVerdict::Undecided(_)
570        ));
571        assert_eq!(record.object(&object("d4")), ObjectVerdict::NotSelected);
572        // Without a recorded selection nothing is decided.
573        let unrecorded = RuleRecord::of(&CapabilityEvaluation::default(), None);
574        assert!(matches!(
575            unrecorded.object(&object("d1")),
576            ObjectVerdict::Undecided(_)
577        ));
578    }
579
580    #[test]
581    fn whole_gates_open_close_or_stay_undecided_with_the_parent() {
582        let parent = RuleId::new("type").unwrap();
583        let passed = GateCondition::AllIfPassed;
584        assert_eq!(gate(&parent, passed, RuleVerdict::Passed), Gate::Open);
585        assert_eq!(gate(&parent, passed, RuleVerdict::Failed), Gate::Closed);
586        assert_eq!(gate(&parent, passed, RuleVerdict::Skipped), Gate::Closed);
587        assert!(matches!(
588            gate(&parent, GateCondition::AllIfFailed, RuleVerdict::Undecided),
589            Gate::Undecided(_)
590        ));
591        assert_eq!(
592            gate(
593                &parent,
594                GateCondition::FailedObjects,
595                RuleVerdict::Undecided
596            ),
597            Gate::Open
598        );
599    }
600}