Skip to main content

axioval_engine/
derived.rs

1//! Properties the engine derives instead of reading them from a source: the
2//! class names a ruleset's classifications assign
3//! ([`axioval_ir::CLASSIFICATION_SET`]) and values measured from geometry
4//! ([`axioval_ir::MEASURED_SET`], see [`crate::measured`]).
5//!
6//! The runtime answers them through the one property-resolution handle, so
7//! every selector and capability reads a derived property exactly as it
8//! reads a stated one. What cannot be derived is an error of the request,
9//! never an absence.
10
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14use axioval_ir::contract::{ClassificationDefinition, ClassificationMode};
15use axioval_ir::{
16    CLASSIFICATION_SET, Evidence, MEASURED_SET, NotEvaluatedReason, ObjectId, Property,
17    PropertyValue,
18};
19
20use crate::measured::Measures;
21use crate::properties::{
22    CompletePropertyAbsenceEvidence, PropertyEnumeration, PropertyEnumerationRequest,
23    PropertyRequest, PropertyResolution, PropertyResolutionError, PropertyResolutionService,
24    PropertyResolutionServiceHandle, ResolvedProperty,
25};
26use crate::session::SourceSnapshot;
27use crate::{OutcomeRefiner, RuleContext, SelectorVerdict};
28
29/// What one classification assigned one object.
30#[derive(Clone, Debug, PartialEq)]
31pub enum ClassOutcome {
32    /// The class names of the matching rows, distinct in row order (one
33    /// for a first-match classification), with the rows' indices.
34    Classified {
35        classes: Vec<String>,
36        rows: Vec<usize>,
37    },
38    /// Every row surely does not match.
39    Unclassified,
40    /// A row that could decide the class could not be decided, and why.
41    Undecided(NotEvaluatedReason, String),
42}
43
44/// Every object's classes under every classification of a run.
45#[derive(Clone, Debug, Default)]
46pub struct Classifications {
47    modes: BTreeMap<String, ClassificationMode>,
48    outcomes: BTreeMap<String, BTreeMap<ObjectId, ClassOutcome>>,
49}
50
51impl Classifications {
52    /// Whether the run declares the classification `id`.
53    #[must_use]
54    pub fn contains(&self, id: &str) -> bool {
55        self.outcomes.contains_key(id)
56    }
57
58    /// What the classification `id` assigned `object`; `None` for an
59    /// undeclared classification or an object outside the project.
60    #[must_use]
61    pub fn outcome(&self, id: &str, object: &ObjectId) -> Option<&ClassOutcome> {
62        self.outcomes.get(id)?.get(object)
63    }
64
65    /// Classifies every object of the context's project by `definition`,
66    /// its rows evaluated by `refiner`, and adds the result.
67    pub(crate) fn classify(
68        &mut self,
69        refiner: &dyn OutcomeRefiner,
70        context: &RuleContext<'_>,
71        definition: &ClassificationDefinition,
72    ) {
73        let outcomes = context
74            .project
75            .objects()
76            .map(|object| {
77                let rows = definition
78                    .rows
79                    .iter()
80                    .map(|row| refiner.evaluate_selector(context, &row.selector, object));
81                (object.id.clone(), classify_one(definition, rows))
82            })
83            .collect();
84        self.modes.insert(definition.id.clone(), definition.mode);
85        self.outcomes.insert(definition.id.clone(), outcomes);
86    }
87
88    fn resolve(
89        &self,
90        request: &PropertyRequest,
91    ) -> Result<PropertyResolution, PropertyResolutionError> {
92        let id = request.property();
93        let object = request.object_id();
94        let Some(outcome) = self.outcome(id, object) else {
95            return Err(if self.contains(id) {
96                PropertyResolutionError::Unavailable(format!(
97                    "{object} is not an object of this run"
98                ))
99            } else {
100                PropertyResolutionError::InvalidRequest
101            });
102        };
103        let evidence = |locator: String| Evidence::exact(object.source.clone(), locator);
104        match outcome {
105            ClassOutcome::Classified { classes, rows } => {
106                let value = match self.modes[id] {
107                    ClassificationMode::FirstMatch => PropertyValue::String(classes[0].clone()),
108                    ClassificationMode::AllMatch => PropertyValue::List(
109                        classes.iter().cloned().map(PropertyValue::String).collect(),
110                    ),
111                };
112                let rows = rows
113                    .iter()
114                    .map(ToString::to_string)
115                    .collect::<Vec<_>>()
116                    .join(",");
117                let property = Property::new(CLASSIFICATION_SET, id, value)
118                    .map_err(|_| PropertyResolutionError::InvalidRequest)?
119                    .with_evidence(evidence(format!("{CLASSIFICATION_SET}/{id}#rows={rows}")));
120                Ok(PropertyResolution::Present(ResolvedProperty::try_new(
121                    request.clone(),
122                    property,
123                )?))
124            }
125            ClassOutcome::Unclassified => Ok(PropertyResolution::Absent(
126                CompletePropertyAbsenceEvidence::try_new(
127                    request.clone(),
128                    evidence(format!("{CLASSIFICATION_SET}/{id}#no-row")),
129                )?,
130            )),
131            ClassOutcome::Undecided(reason, message) => Err(match reason {
132                NotEvaluatedReason::NotRecorded => {
133                    PropertyResolutionError::NotRecorded(message.clone())
134                }
135                NotEvaluatedReason::MissingService | NotEvaluatedReason::BackendUnavailable => {
136                    PropertyResolutionError::Unavailable(message.clone())
137                }
138                _ => PropertyResolutionError::Incomplete(message.clone()),
139            }),
140        }
141    }
142}
143
144/// One object's classes from its rows' verdicts, in row order.
145///
146/// First match: the first matching row decides, once every row before it
147/// surely does not match; an undecided row before it leaves the object
148/// undecided. All match: every row must be decided.
149fn classify_one(
150    definition: &ClassificationDefinition,
151    rows: impl Iterator<Item = SelectorVerdict>,
152) -> ClassOutcome {
153    let first = definition.mode == ClassificationMode::FirstMatch;
154    let mut classes: Vec<String> = Vec::new();
155    let mut matched = Vec::new();
156    for (index, verdict) in rows.enumerate() {
157        match verdict {
158            SelectorVerdict::Match(_) => {
159                let class = &definition.rows[index].class;
160                if !classes.contains(class) {
161                    classes.push(class.clone());
162                }
163                matched.push(index);
164                if first {
165                    break;
166                }
167            }
168            SelectorVerdict::NoMatch(_) => {}
169            SelectorVerdict::Undecided(reason, message) => {
170                return ClassOutcome::Undecided(
171                    reason,
172                    format!(
173                        "row {index} of classification `{}` cannot be decided: {message}",
174                        definition.id
175                    ),
176                );
177            }
178        }
179    }
180    if classes.is_empty() {
181        ClassOutcome::Unclassified
182    } else {
183        ClassOutcome::Classified {
184            classes,
185            rows: matched,
186        }
187    }
188}
189
190/// The run's property resolver: derived sets answered by the engine, every
191/// other request by the host's resolver.
192pub(crate) struct DerivedProperties {
193    pub(crate) inner: Option<PropertyResolutionServiceHandle>,
194    pub(crate) measures: Measures,
195    pub(crate) classifications: Arc<Classifications>,
196    pub(crate) snapshots: Vec<SourceSnapshot>,
197}
198
199impl PropertyResolutionService for DerivedProperties {
200    fn source_snapshots(&self) -> &[SourceSnapshot] {
201        &self.snapshots
202    }
203
204    fn resolve(
205        &self,
206        request: &PropertyRequest,
207    ) -> Result<PropertyResolution, PropertyResolutionError> {
208        if request.property_set() == Some(CLASSIFICATION_SET) {
209            return self.classifications.resolve(request);
210        }
211        if request.property_set() == Some(MEASURED_SET) {
212            return self.measures.resolve(request);
213        }
214        match &self.inner {
215            Some(inner) => inner.resolve(request),
216            None => Err(PropertyResolutionError::Unavailable(
217                "property-resolution service is not registered".into(),
218            )),
219        }
220    }
221
222    fn enumerate(
223        &self,
224        request: &PropertyEnumerationRequest,
225    ) -> Result<PropertyEnumeration, PropertyResolutionError> {
226        match &self.inner {
227            Some(inner) => inner.enumerate(request),
228            None => Err(PropertyResolutionError::Unavailable(
229                "property-resolution service is not registered".into(),
230            )),
231        }
232    }
233}
234
235/// Installs the run's resolver over the host's in `services`: derived
236/// sets answered from `classifications`, everything else by the host.
237pub(crate) fn install(
238    services: &mut crate::ServiceRegistry,
239    host: Option<&PropertyResolutionServiceHandle>,
240    classifications: Arc<Classifications>,
241    project: &axioval_ir::Project,
242) {
243    use crate::SnapshotBoundService as _;
244    let snapshots = host.map_or_else(Vec::new, |host| host.source_snapshots().to_vec());
245    services.replace(PropertyResolutionServiceHandle::new(Arc::new(
246        DerivedProperties {
247            inner: host.cloned(),
248            measures: Measures::of(services, host, project),
249            classifications: classifications.clone(),
250            snapshots,
251        },
252    )));
253    services.replace(classifications);
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use axioval_ir::contract::{ClassificationRow, LocalizedText, Selector};
260
261    fn definition(mode: ClassificationMode) -> ClassificationDefinition {
262        let row = |class: &str| ClassificationRow {
263            selector: Selector::All,
264            class: class.into(),
265        };
266        ClassificationDefinition {
267            id: "use".into(),
268            name: LocalizedText::plain("use"),
269            description: None,
270            mode,
271            rows: vec![row("office"), row("lab"), row("office")],
272        }
273    }
274
275    fn verdicts(verdicts: &[Option<bool>]) -> impl Iterator<Item = SelectorVerdict> + '_ {
276        verdicts.iter().map(|verdict| match verdict {
277            Some(true) => SelectorVerdict::Match(Vec::new()),
278            Some(false) => SelectorVerdict::NoMatch(Vec::new()),
279            None => SelectorVerdict::Undecided(NotEvaluatedReason::InvalidEvidence, "?".into()),
280        })
281    }
282
283    #[test]
284    fn the_first_decided_match_wins_and_an_undecided_row_before_it_decides_nothing() {
285        let first = definition(ClassificationMode::FirstMatch);
286        assert_eq!(
287            classify_one(&first, verdicts(&[Some(false), Some(true), None])),
288            ClassOutcome::Classified {
289                classes: vec!["lab".into()],
290                rows: vec![1]
291            }
292        );
293        assert!(matches!(
294            classify_one(&first, verdicts(&[None, Some(true), Some(true)])),
295            ClassOutcome::Undecided(..)
296        ));
297        assert_eq!(
298            classify_one(&first, verdicts(&[Some(false); 3])),
299            ClassOutcome::Unclassified
300        );
301    }
302
303    #[test]
304    fn all_match_names_every_matching_class_once_and_needs_every_row() {
305        let all = definition(ClassificationMode::AllMatch);
306        assert_eq!(
307            classify_one(&all, verdicts(&[Some(true), Some(true), Some(true)])),
308            ClassOutcome::Classified {
309                classes: vec!["office".into(), "lab".into()],
310                rows: vec![0, 1, 2]
311            }
312        );
313        assert!(matches!(
314            classify_one(&all, verdicts(&[Some(true), Some(false), None])),
315            ClassOutcome::Undecided(..)
316        ));
317    }
318}