use std::collections::BTreeMap;
use std::sync::Arc;
use axioval_ir::contract::{ClassificationDefinition, ClassificationMode};
use axioval_ir::{
CLASSIFICATION_SET, Evidence, MEASURED_SET, NotEvaluatedReason, ObjectId, Property,
PropertyValue,
};
use crate::measured::Measures;
use crate::properties::{
CompletePropertyAbsenceEvidence, PropertyEnumeration, PropertyEnumerationRequest,
PropertyRequest, PropertyResolution, PropertyResolutionError, PropertyResolutionService,
PropertyResolutionServiceHandle, ResolvedProperty,
};
use crate::session::SourceSnapshot;
use crate::{OutcomeRefiner, RuleContext, SelectorVerdict};
#[derive(Clone, Debug, PartialEq)]
pub enum ClassOutcome {
Classified {
classes: Vec<String>,
rows: Vec<usize>,
},
Unclassified,
Undecided(NotEvaluatedReason, String),
}
#[derive(Clone, Debug, Default)]
pub struct Classifications {
modes: BTreeMap<String, ClassificationMode>,
outcomes: BTreeMap<String, BTreeMap<ObjectId, ClassOutcome>>,
}
impl Classifications {
#[must_use]
pub fn contains(&self, id: &str) -> bool {
self.outcomes.contains_key(id)
}
#[must_use]
pub fn outcome(&self, id: &str, object: &ObjectId) -> Option<&ClassOutcome> {
self.outcomes.get(id)?.get(object)
}
pub(crate) fn classify(
&mut self,
refiner: &dyn OutcomeRefiner,
context: &RuleContext<'_>,
definition: &ClassificationDefinition,
) {
let outcomes = context
.project
.objects()
.map(|object| {
let rows = definition
.rows
.iter()
.map(|row| refiner.evaluate_selector(context, &row.selector, object));
(object.id.clone(), classify_one(definition, rows))
})
.collect();
self.modes.insert(definition.id.clone(), definition.mode);
self.outcomes.insert(definition.id.clone(), outcomes);
}
fn resolve(
&self,
request: &PropertyRequest,
) -> Result<PropertyResolution, PropertyResolutionError> {
let id = request.property();
let object = request.object_id();
let Some(outcome) = self.outcome(id, object) else {
return Err(if self.contains(id) {
PropertyResolutionError::Unavailable(format!(
"{object} is not an object of this run"
))
} else {
PropertyResolutionError::InvalidRequest
});
};
let evidence = |locator: String| Evidence::exact(object.source.clone(), locator);
match outcome {
ClassOutcome::Classified { classes, rows } => {
let value = match self.modes[id] {
ClassificationMode::FirstMatch => PropertyValue::String(classes[0].clone()),
ClassificationMode::AllMatch => PropertyValue::List(
classes.iter().cloned().map(PropertyValue::String).collect(),
),
};
let rows = rows
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
let property = Property::new(CLASSIFICATION_SET, id, value)
.map_err(|_| PropertyResolutionError::InvalidRequest)?
.with_evidence(evidence(format!("{CLASSIFICATION_SET}/{id}#rows={rows}")));
Ok(PropertyResolution::Present(ResolvedProperty::try_new(
request.clone(),
property,
)?))
}
ClassOutcome::Unclassified => Ok(PropertyResolution::Absent(
CompletePropertyAbsenceEvidence::try_new(
request.clone(),
evidence(format!("{CLASSIFICATION_SET}/{id}#no-row")),
)?,
)),
ClassOutcome::Undecided(reason, message) => Err(match reason {
NotEvaluatedReason::NotRecorded => {
PropertyResolutionError::NotRecorded(message.clone())
}
NotEvaluatedReason::MissingService | NotEvaluatedReason::BackendUnavailable => {
PropertyResolutionError::Unavailable(message.clone())
}
_ => PropertyResolutionError::Incomplete(message.clone()),
}),
}
}
}
fn classify_one(
definition: &ClassificationDefinition,
rows: impl Iterator<Item = SelectorVerdict>,
) -> ClassOutcome {
let first = definition.mode == ClassificationMode::FirstMatch;
let mut classes: Vec<String> = Vec::new();
let mut matched = Vec::new();
for (index, verdict) in rows.enumerate() {
match verdict {
SelectorVerdict::Match(_) => {
let class = &definition.rows[index].class;
if !classes.contains(class) {
classes.push(class.clone());
}
matched.push(index);
if first {
break;
}
}
SelectorVerdict::NoMatch(_) => {}
SelectorVerdict::Undecided(reason, message) => {
return ClassOutcome::Undecided(
reason,
format!(
"row {index} of classification `{}` cannot be decided: {message}",
definition.id
),
);
}
}
}
if classes.is_empty() {
ClassOutcome::Unclassified
} else {
ClassOutcome::Classified {
classes,
rows: matched,
}
}
}
pub(crate) struct DerivedProperties {
pub(crate) inner: Option<PropertyResolutionServiceHandle>,
pub(crate) measures: Measures,
pub(crate) classifications: Arc<Classifications>,
pub(crate) snapshots: Vec<SourceSnapshot>,
}
impl PropertyResolutionService for DerivedProperties {
fn source_snapshots(&self) -> &[SourceSnapshot] {
&self.snapshots
}
fn resolve(
&self,
request: &PropertyRequest,
) -> Result<PropertyResolution, PropertyResolutionError> {
if request.property_set() == Some(CLASSIFICATION_SET) {
return self.classifications.resolve(request);
}
if request.property_set() == Some(MEASURED_SET) {
return self.measures.resolve(request);
}
match &self.inner {
Some(inner) => inner.resolve(request),
None => Err(PropertyResolutionError::Unavailable(
"property-resolution service is not registered".into(),
)),
}
}
fn enumerate(
&self,
request: &PropertyEnumerationRequest,
) -> Result<PropertyEnumeration, PropertyResolutionError> {
match &self.inner {
Some(inner) => inner.enumerate(request),
None => Err(PropertyResolutionError::Unavailable(
"property-resolution service is not registered".into(),
)),
}
}
}
pub(crate) fn install(
services: &mut crate::ServiceRegistry,
host: Option<&PropertyResolutionServiceHandle>,
classifications: Arc<Classifications>,
project: &axioval_ir::Project,
) {
use crate::SnapshotBoundService as _;
let snapshots = host.map_or_else(Vec::new, |host| host.source_snapshots().to_vec());
services.replace(PropertyResolutionServiceHandle::new(Arc::new(
DerivedProperties {
inner: host.cloned(),
measures: Measures::of(services, host, project),
classifications: classifications.clone(),
snapshots,
},
)));
services.replace(classifications);
}
#[cfg(test)]
mod tests {
use super::*;
use axioval_ir::contract::{ClassificationRow, LocalizedText, Selector};
fn definition(mode: ClassificationMode) -> ClassificationDefinition {
let row = |class: &str| ClassificationRow {
selector: Selector::All,
class: class.into(),
};
ClassificationDefinition {
id: "use".into(),
name: LocalizedText::plain("use"),
description: None,
mode,
rows: vec![row("office"), row("lab"), row("office")],
}
}
fn verdicts(verdicts: &[Option<bool>]) -> impl Iterator<Item = SelectorVerdict> + '_ {
verdicts.iter().map(|verdict| match verdict {
Some(true) => SelectorVerdict::Match(Vec::new()),
Some(false) => SelectorVerdict::NoMatch(Vec::new()),
None => SelectorVerdict::Undecided(NotEvaluatedReason::InvalidEvidence, "?".into()),
})
}
#[test]
fn the_first_decided_match_wins_and_an_undecided_row_before_it_decides_nothing() {
let first = definition(ClassificationMode::FirstMatch);
assert_eq!(
classify_one(&first, verdicts(&[Some(false), Some(true), None])),
ClassOutcome::Classified {
classes: vec!["lab".into()],
rows: vec![1]
}
);
assert!(matches!(
classify_one(&first, verdicts(&[None, Some(true), Some(true)])),
ClassOutcome::Undecided(..)
));
assert_eq!(
classify_one(&first, verdicts(&[Some(false); 3])),
ClassOutcome::Unclassified
);
}
#[test]
fn all_match_names_every_matching_class_once_and_needs_every_row() {
let all = definition(ClassificationMode::AllMatch);
assert_eq!(
classify_one(&all, verdicts(&[Some(true), Some(true), Some(true)])),
ClassOutcome::Classified {
classes: vec!["office".into(), "lab".into()],
rows: vec![0, 1, 2]
}
);
assert!(matches!(
classify_one(&all, verdicts(&[Some(true), Some(false), None])),
ClassOutcome::Undecided(..)
));
}
}