1use 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#[derive(Clone, Debug, PartialEq)]
31pub enum ClassOutcome {
32 Classified {
35 classes: Vec<String>,
36 rows: Vec<usize>,
37 },
38 Unclassified,
40 Undecided(NotEvaluatedReason, String),
42}
43
44#[derive(Clone, Debug, Default)]
46pub struct Classifications {
47 modes: BTreeMap<String, ClassificationMode>,
48 outcomes: BTreeMap<String, BTreeMap<ObjectId, ClassOutcome>>,
49}
50
51impl Classifications {
52 #[must_use]
54 pub fn contains(&self, id: &str) -> bool {
55 self.outcomes.contains_key(id)
56 }
57
58 #[must_use]
61 pub fn outcome(&self, id: &str, object: &ObjectId) -> Option<&ClassOutcome> {
62 self.outcomes.get(id)?.get(object)
63 }
64
65 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
144fn 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
190pub(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
235pub(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}