diff --git a/CHANGELOG.md b/CHANGELOG.md index 1adcec0a..ff970a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,24 @@ All notable changes are documented here. This project follows Semantic Versionin Results now carry an `objects` index (kind and GlobalId of every object the report names). On a real model the summary is 712 bytes against 271 KB of JSON, so an agent can read the shape first and fetch entries on demand. +- **Declared property types.** `Property::data_type` carries the value's type + as the source declares it, and the IFC session reports it (`IFCLABEL`, + `IFCBOOLEAN`, ...). Unreported is `None` and serializes as before. This is a + breaking change for code that builds `Property` with a struct literal. +- **`axioval:capability.property-data-type`.** A required, non-empty + property whose declared type equals `data_type`. Another type is a finding; + an unreported type is not evaluated. Measure-typed IFC values + (`IFCLENGTHMEASURE`, ...) stay not evaluated while the adapter refuses them + pending unit handling. +- **`axioval:capability.property-value`.** Values, XML Schema patterns, + numeric bounds and lengths, written as lexical strings and cast to the + resolved value's kind; decimals compare with the IDS tolerance, bounds + exactly. An `optional` rule passes absent and `null` properties. Anything + that cannot be applied to a value is not evaluated. +- **More IFC property types.** The IFC session carries every value whose + defined type is `STRING`-, `INTEGER`- or `NUMBER`-based (`IfcDate`, + `IfcDuration`, `IfcTimeStamp`, `IfcCountMeasure`, ...) with its declared type, + instead of refusing it. Unit-bearing real measures are still refused. - **`axioval check`.** Runs a ruleset over an IFC2X3 or IFC4 model and writes the report and the model's integrity issues as JSON, and optionally a BCF 2.1 archive (`--bcf`). Exit status separates a clean pass (0), findings (3) @@ -100,6 +118,17 @@ All notable changes are documented here. This project follows Semantic Versionin not duplicates, a wall overlapping a space was not on the envelope, and a closed deck had no edges to guard. Existing tests used same-winding caps and never saw it; each service now has a closed-body regression test. +- **A quantity was reported as an exact absence.** `ifc-properties` resolves + `IfcPropertySet` members only and skips quantity sets + (`IfcElementQuantity`) and predefined property sets + (`IfcDoorLiningProperties`, ...), yet the IFC property service turned its + "absent" into complete absence evidence. A rule requiring `Foo` in a + quantity set `Foo_Bar` therefore reported a wall as missing a quantity it + carried. Absence is now refused as incomplete (not evaluated) when the + requested set is one of those definitions, or, for an unqualified request, + when one of them has a member of that name. The check is model-wide, so it + can only turn an absence into not evaluated. Found by running translated + buildingSMART IDS test cases. - **Classification selectors silently passed over sources.** A `classification` selector read the project's inline classification list, which no production adapter fills. Over an IFC model every classification diff --git a/crates/contracts/ir/src/lib.rs b/crates/contracts/ir/src/lib.rs index 53718790..3de9dec1 100644 --- a/crates/contracts/ir/src/lib.rs +++ b/crates/contracts/ir/src/lib.rs @@ -187,6 +187,12 @@ pub struct Property { pub property_set: String, pub name: String, pub value: PropertyValue, + /// The value's type as the source declares it, in the source's own + /// vocabulary (an IFC property: `IFCLABEL`, `IFCLENGTHMEASURE`). `None` + /// when the source declares none or the adapter does not report it, + /// which is never evidence of any particular type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_type: Option, pub evidence: Option, } impl Property { @@ -200,14 +206,28 @@ impl Property { property_set: required(property_set, "property set")?, name: required(name, "property name")?, value, + data_type: None, evidence: None, }) } + /// Records the value's type as the source declares it. + /// + /// # Errors + /// + /// Returns an error when `data_type` is blank. + pub fn with_data_type(mut self, data_type: impl Into) -> Result { + self.data_type = Some(required(data_type, "property data type")?); + Ok(self) + } /// Attaches source evidence. pub fn with_evidence(mut self, evidence: Evidence) -> Self { self.evidence = Some(evidence); self } + /// The value's type as the source declares it, if reported. + pub fn data_type(&self) -> Option<&str> { + self.data_type.as_deref() + } /// Returns the typed property value. pub fn value(&self) -> &PropertyValue { &self.value diff --git a/crates/contracts/ir/tests/contracts.rs b/crates/contracts/ir/tests/contracts.rs index 660a72c1..7ea9bd2a 100644 --- a/crates/contracts/ir/tests/contracts.rs +++ b/crates/contracts/ir/tests/contracts.rs @@ -131,3 +131,22 @@ fn objects_without_external_ids_keep_their_serialized_shape() { let back: Object = serde_json::from_str(&json).unwrap(); assert!(back.external_ids.is_empty()); } + +#[test] +fn a_property_carries_its_declared_data_type_only_when_reported() { + let untyped = Property::new("Pset", "Code", PropertyValue::String("A".into())).unwrap(); + let json = serde_json::to_string(&untyped).unwrap(); + // Properties without a reported type keep their serialized shape. + assert!(!json.contains("data_type"), "{json}"); + assert_eq!(untyped.data_type(), None); + + let typed = untyped.with_data_type("IFCLABEL").unwrap(); + let back: Property = serde_json::from_str(&serde_json::to_string(&typed).unwrap()).unwrap(); + assert_eq!(back.data_type(), Some("IFCLABEL")); + assert_eq!(back, typed); + + let blank = Property::new("Pset", "Code", PropertyValue::Null) + .unwrap() + .with_data_type(" "); + assert!(matches!(blank, Err(IrError::Blank { .. }))); +} diff --git a/crates/engine/rules/AGENTS.md b/crates/engine/rules/AGENTS.md index 9f4895d6..3a410d75 100644 --- a/crates/engine/rules/AGENTS.md +++ b/crates/engine/rules/AGENTS.md @@ -4,7 +4,7 @@ Maintained source-neutral capability policy and shared rule algorithms. Capabilities may depend on Axioval IR and typed host-service interfaces, never concrete OpenBIM, ICDD, Axiolid, STEP, or vendor CAD types. Source interpretation belongs in adapters. -`property_rules.rs` owns property compliance policy; `selection.rs` owns fail-closed selector evaluation. Both must resolve property values and absences through `PropertyResolutionServiceHandle`. Never infer absence from a missing `Object.properties` entry, and never silently skip an object when a property selector cannot be resolved exactly. `property-exists` means exact presence even when a source value is null or blank; `property-required` is the stronger non-empty contract and treats exact absence, null, and blank text as violations without converting adapter failures into findings. +`property_rules.rs` owns property compliance policy; `selection.rs` owns fail-closed selector evaluation. Both must resolve property values and absences through `PropertyResolutionServiceHandle`. Never infer absence from a missing `Object.properties` entry, and never silently skip an object when a property selector cannot be resolved exactly. `property-exists` means exact presence even when a source value is null or blank; `property-required` is the stronger non-empty contract and treats exact absence, null, and blank text as violations without converting adapter failures into findings. `property-data-type` adds the source-declared type to that contract; an unreported type is not evaluated, never a match. `property_value.rs` owns `property-value`: lexical constraints cast to the resolved value's kind. A literal or constraint that does not fit the value is not evaluated, never a pass or a violation. `xsd_pattern.rs` translates XML Schema patterns; refuse any construct it cannot map exactly rather than approximate it. `free_floor_circle.rs` and `free_floor_rectangle.rs` own exact grounded vertical-shape profiles. They must request whole-base support and all project objects as candidate obstacles. Missing services, backend failures, and invalid/incomplete proofs emit typed not-evaluated outcomes; they never produce a pass or compliance finding. diff --git a/crates/engine/rules/src/lib.rs b/crates/engine/rules/src/lib.rs index 05ad3345..12666e67 100644 --- a/crates/engine/rules/src/lib.rs +++ b/crates/engine/rules/src/lib.rs @@ -14,10 +14,12 @@ mod horizontal_guard; mod pairs; mod property_comparison; mod property_rules; +mod property_value; mod selection; mod shelf_capacity; mod slab_contact; mod space_validation; +mod xsd_pattern; pub use clash::Clash; pub use comparison::{ @@ -32,8 +34,9 @@ pub use guard_diagnosis::{GuardDefect, GuardDiagnosis}; pub use horizontal_guard::HorizontalGuard; pub use property_comparison::PropertyComparison; pub use property_rules::{ - BooleanPropertyEquals, PropertyExists, PropertyPredicate, PropertyRequired, + BooleanPropertyEquals, PropertyDataType, PropertyExists, PropertyPredicate, PropertyRequired, }; +pub use property_value::PropertyValueConstraint; pub use shelf_capacity::ShelfCapacity; pub use slab_contact::SlabContact; pub use space_validation::SpaceValidation; @@ -47,6 +50,8 @@ pub fn register_builtins(registry: CapabilityRegistry) -> Result { - let value = &resolved.property().value; - if matches!(value, PropertyValue::Null) - || matches!(value, PropertyValue::String(text) if text.trim().is_empty()) - { + if is_empty_value(&resolved.property().value) { evaluation.push_finding(finding( rule, object, @@ -200,6 +197,100 @@ impl RuleCapability for PropertyRequired { } } +/// Whether a resolved value counts as empty for a required property. +fn is_empty_value(value: &PropertyValue) -> bool { + matches!(value, PropertyValue::Null) + || matches!(value, PropertyValue::String(text) if text.trim().is_empty()) +} + +/// Requires a non-empty property whose source-declared type is `data_type`. +/// +/// Absence, `null` and blank text are violations as for `property-required`. +/// A present value of another declared type is a violation. A present value +/// whose type the source did not report is not evaluated: an unknown type is +/// never taken to match. Type names compare ASCII case-insensitively, since +/// STEP-based sources do not distinguish case. +pub struct PropertyDataType; +impl RuleCapability for PropertyDataType { + fn id(&self) -> &'static str { + "axioval:capability.property-data-type" + } + + fn parameters(&self) -> Vec { + vec![ + ParameterDescriptor::required("property", ParameterType::PropertyReference), + ParameterDescriptor::required("data_type", ParameterType::String), + ] + } + + fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation { + let (Some((set, name)), Some(expected)) = ( + property_reference(rule, "property"), + string(rule, "data_type").filter(|value| !value.trim().is_empty()), + ) else { + return CapabilityEvaluation::not_evaluated( + NotEvaluatedReason::InvalidDeclaration, + "property-data-type parameters are invalid", + ); + }; + let (selected, mut evaluation) = select_objects(context, &rule.selector); + let Some(service) = context.services.get::() else { + return unavailable_selected( + &selected, + &NotEvaluatedReason::MissingService, + "property-resolution service is not registered", + evaluation, + ); + }; + for object in selected { + let request = match bound_property_request(context, object, set, name) { + Ok(request) => request, + Err((reason, message)) => { + evaluation.push_object_not_evaluated(object.id.clone(), reason, message); + continue; + } + }; + match service.resolve(&request) { + Ok(PropertyResolution::Present(resolved)) => { + let property = resolved.property(); + let evidence = property.evidence.clone().into_iter().collect(); + if is_empty_value(&property.value) { + evaluation.push_finding(finding( + rule, + object, + format!("missing required property {name}"), + evidence, + )); + } else { + match property.data_type() { + Some(actual) if actual.eq_ignore_ascii_case(expected) => {} + Some(actual) => evaluation.push_finding(finding( + rule, + object, + format!("property {name} is {actual}, not {expected}"), + evidence, + )), + None => evaluation.push_object_not_evaluated( + object.id.clone(), + NotEvaluatedReason::IncompleteEvidence, + format!("the source does not report the type of property {name}"), + ), + } + } + } + Ok(PropertyResolution::Absent(proof)) => evaluation.push_finding(finding( + rule, + object, + format!("missing required property {name}"), + vec![proof.evidence().clone()], + )), + Err(error) => resolve_error(&mut evaluation, object, error), + } + } + evaluation + } +} + fn boolean(rule: &CompiledRule, name: &str) -> Option { match rule.parameters.get(name)? { ParameterValue::Boolean { value } => Some(*value), diff --git a/crates/engine/rules/src/property_value.rs b/crates/engine/rules/src/property_value.rs new file mode 100644 index 00000000..27e54413 --- /dev/null +++ b/crates/engine/rules/src/property_value.rs @@ -0,0 +1,560 @@ +//! Value constraints on an exactly resolved property. +//! +//! Constraints are written as lexical strings, the way XML Schema facets are, +//! and cast to the kind of the value the source resolved: +//! +//! - text compares exactly and case-sensitively, and alone takes `patterns` +//! and lengths; +//! - a boolean accepts `true`/`1` and `false`/`0`; +//! - an integer takes integer literals, and bounds in any numeric form; +//! - a decimal takes `xs:double` literals and equals within the tolerance +//! `|x - v| <= |v|·1e-6 + 1e-6`; bounds compare without tolerance. +//! +//! A literal that cannot be cast to the value's kind, or a constraint the kind +//! does not take, makes the object not evaluated (`InvalidDeclaration`): the +//! declaration cannot be applied to this value, which is neither a pass nor a +//! violation. A quantity is not evaluated: comparing it needs units. + +use axioval_engine::{ + CapabilityEvaluation, CompiledRule, NotEvaluatedReason, ParameterDescriptor, ParameterType, + PropertyResolution, PropertyResolutionServiceHandle, RuleCapability, RuleContext, +}; +use axioval_ir::contract::ParameterValue; +use axioval_ir::{Evidence, Finding, Object, Property, PropertyValue, Severity}; + +use crate::selection::{bound_property_request, property_error, select_objects}; +use crate::xsd_pattern; + +/// IDS equality tolerance for doubles, relative and absolute. +const EPSILON: f64 = 1.0e-6; + +/// The declared constraints of one rule. +#[derive(Default)] +struct Constraints<'r> { + data_type: Option<&'r str>, + values: &'r [String], + patterns: &'r [String], + min_inclusive: Option<&'r str>, + max_inclusive: Option<&'r str>, + min_exclusive: Option<&'r str>, + max_exclusive: Option<&'r str>, + length: Option, + min_length: Option, + max_length: Option, + optional: bool, +} + +impl<'r> Constraints<'r> { + fn read(rule: &'r CompiledRule) -> Result { + let text = |name: &str| match rule.parameters.get(name) { + Some(ParameterValue::String { value }) => Some(value.as_str()), + _ => None, + }; + let list = |name: &str| match rule.parameters.get(name) { + Some(ParameterValue::StringList { value }) => value.as_slice(), + _ => &[], + }; + let count = |name: &str| match rule.parameters.get(name) { + Some(ParameterValue::Integer { value }) => Some(*value), + _ => None, + }; + let constraints = Self { + data_type: text("data_type"), + values: list("values"), + patterns: list("patterns"), + min_inclusive: text("min_inclusive"), + max_inclusive: text("max_inclusive"), + min_exclusive: text("min_exclusive"), + max_exclusive: text("max_exclusive"), + length: count("length"), + min_length: count("min_length"), + max_length: count("max_length"), + optional: matches!( + rule.parameters.get("optional"), + Some(ParameterValue::Boolean { value: true }) + ), + }; + if constraints + .data_type + .is_some_and(|value| value.trim().is_empty()) + { + return Err("data_type is blank".into()); + } + if [ + constraints.length, + constraints.min_length, + constraints.max_length, + ] + .into_iter() + .flatten() + .any(|count| count < 0) + { + return Err("a length is negative".into()); + } + if constraints.data_type.is_none() && !constraints.constrains_value() { + return Err("no data type and no value constraint".into()); + } + Ok(constraints) + } + + fn constrains_value(&self) -> bool { + !self.values.is_empty() + || !self.patterns.is_empty() + || self.has_bounds() + || self.has_lengths() + } + + fn has_bounds(&self) -> bool { + self.min_inclusive.is_some() + || self.max_inclusive.is_some() + || self.min_exclusive.is_some() + || self.max_exclusive.is_some() + } + + fn has_lengths(&self) -> bool { + self.length.is_some() || self.min_length.is_some() || self.max_length.is_some() + } +} + +/// Whether a present value meets the constraints. +enum Verdict { + Meets, + Fails(String), + /// The constraints cannot be applied to this value. + Inapplicable(NotEvaluatedReason, String), +} + +/// Requires a property's value to meet lexical constraints, cast to its kind. +/// +/// Parameters: `property`; optionally `data_type` (the source-declared type, +/// as in `property-data-type`), `values` (any of), `patterns` (XML Schema +/// regular expressions, any of, whole value), `min_inclusive`, +/// `max_inclusive`, `min_exclusive`, `max_exclusive`, `length`, +/// `min_length`, `max_length`, and `optional`. All given constraints must +/// hold. Without `optional`, absence, `null` and blank text are violations; +/// with it, an absent or `null` property passes and any present value, +/// empty text included, is checked. +pub struct PropertyValueConstraint; +impl RuleCapability for PropertyValueConstraint { + fn id(&self) -> &'static str { + "axioval:capability.property-value" + } + + fn parameters(&self) -> Vec { + let mut parameters = vec![ParameterDescriptor::required( + "property", + ParameterType::PropertyReference, + )]; + for name in [ + "data_type", + "min_inclusive", + "max_inclusive", + "min_exclusive", + "max_exclusive", + ] { + parameters.push(ParameterDescriptor::optional(name, ParameterType::String)); + } + for name in ["values", "patterns"] { + parameters.push(ParameterDescriptor::optional( + name, + ParameterType::StringList, + )); + } + for name in ["length", "min_length", "max_length"] { + parameters.push(ParameterDescriptor::optional(name, ParameterType::Integer)); + } + parameters.push(ParameterDescriptor::optional( + "optional", + ParameterType::Boolean, + )); + parameters + } + + fn evaluate(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> CapabilityEvaluation { + let Some(ParameterValue::PropertyReference { + property: name, + property_set: set, + }) = rule.parameters.get("property") + else { + return CapabilityEvaluation::not_evaluated( + NotEvaluatedReason::InvalidDeclaration, + "property-value has no valid property reference", + ); + }; + let constraints = match Constraints::read(rule) { + Ok(constraints) => constraints, + Err(message) => { + return CapabilityEvaluation::not_evaluated( + NotEvaluatedReason::InvalidDeclaration, + format!("property-value parameters are invalid: {message}"), + ); + } + }; + let (selected, mut evaluation) = select_objects(context, &rule.selector); + let Some(service) = context.services.get::() else { + for object in selected { + evaluation.push_object_not_evaluated( + object.id.clone(), + NotEvaluatedReason::MissingService, + "property-resolution service is not registered", + ); + } + return evaluation; + }; + for object in selected { + let request = match bound_property_request(context, object, set.as_deref(), name) { + Ok(request) => request, + Err((reason, message)) => { + evaluation.push_object_not_evaluated(object.id.clone(), reason, message); + continue; + } + }; + match service.resolve(&request) { + Ok(PropertyResolution::Present(resolved)) => { + let property = resolved.property(); + match judge(property, name, &constraints) { + Verdict::Meets => {} + Verdict::Fails(message) => evaluation.push_finding(finding( + rule, + object, + message, + property.evidence.clone().into_iter().collect(), + )), + Verdict::Inapplicable(reason, message) => { + evaluation.push_object_not_evaluated( + object.id.clone(), + reason, + message, + ); + } + } + } + Ok(PropertyResolution::Absent(proof)) => { + if !constraints.optional { + evaluation.push_finding(finding( + rule, + object, + format!("missing required property {name}"), + vec![proof.evidence().clone()], + )); + } + } + Err(error) => { + let (reason, message) = property_error(error); + evaluation.push_object_not_evaluated(object.id.clone(), reason, message); + } + } + } + evaluation + } +} + +/// The verdict on a present property: emptiness, declared type, then value. +fn judge(property: &Property, name: &str, constraints: &Constraints<'_>) -> Verdict { + let value = &property.value; + if constraints.optional && matches!(value, PropertyValue::Null) { + return Verdict::Meets; + } + if !constraints.optional && is_empty(value) { + return Verdict::Fails(format!("missing required property {name}")); + } + if let Some(expected) = constraints.data_type { + match property.data_type() { + Some(actual) if actual.eq_ignore_ascii_case(expected) => {} + Some(actual) => { + return Verdict::Fails(format!("property {name} is {actual}, not {expected}")); + } + None => { + return Verdict::Inapplicable( + NotEvaluatedReason::IncompleteEvidence, + format!("the source does not report the type of property {name}"), + ); + } + } + } + match verdict(value, constraints) { + Verdict::Fails(why) => Verdict::Fails(format!("property {name} {why}")), + Verdict::Inapplicable(reason, message) => { + Verdict::Inapplicable(reason, format!("property {name}: {message}")) + } + Verdict::Meets => Verdict::Meets, + } +} + +fn is_empty(value: &PropertyValue) -> bool { + matches!(value, PropertyValue::Null) + || matches!(value, PropertyValue::String(text) if text.trim().is_empty()) +} + +fn finding( + rule: &CompiledRule, + object: &Object, + message: String, + evidence: Vec, +) -> Finding { + Finding { + rule_id: rule.id.clone(), + object_id: object.id.clone(), + related: Vec::new(), + severity: match rule.severity { + axioval_ir::contract::Severity::Error => Severity::Error, + axioval_ir::contract::Severity::Warning => Severity::Warning, + axioval_ir::contract::Severity::Info => Severity::Info, + }, + message, + evidence, + } +} + +fn invalid(message: impl Into) -> Verdict { + Verdict::Inapplicable(NotEvaluatedReason::InvalidDeclaration, message.into()) +} + +fn verdict(value: &PropertyValue, constraints: &Constraints<'_>) -> Verdict { + match value { + PropertyValue::String(text) => text_verdict(text, constraints), + PropertyValue::Boolean(actual) => { + if !constraints.patterns.is_empty() + || constraints.has_bounds() + || constraints.has_lengths() + { + return invalid("a boolean takes only values"); + } + one_of( + constraints.values, + |literal| match literal { + "true" | "1" => Ok(*actual), + "false" | "0" => Ok(!*actual), + other => Err(format!("{other:?} is not a boolean literal")), + }, + &actual.to_string(), + ) + } + PropertyValue::Integer(actual) => { + if !constraints.patterns.is_empty() || constraints.has_lengths() { + return invalid("a number takes no patterns or lengths"); + } + let equal = one_of( + constraints.values, + |literal| { + parse_integer(literal) + .map(|expected| expected == *actual) + .ok_or_else(|| format!("{literal:?} is not an integer literal")) + }, + &actual.to_string(), + ); + if !matches!(equal, Verdict::Meets) { + return equal; + } + #[allow(clippy::cast_precision_loss)] + bounds(Number::Integer(*actual), *actual as f64, constraints) + } + PropertyValue::Decimal(actual) => { + if !constraints.patterns.is_empty() || constraints.has_lengths() { + return invalid("a number takes no patterns or lengths"); + } + let equal = one_of( + constraints.values, + |literal| { + parse_double(literal) + .map(|expected| within_tolerance(*actual, expected)) + .ok_or_else(|| format!("{literal:?} is not a number literal")) + }, + &actual.to_string(), + ); + if !matches!(equal, Verdict::Meets) { + return equal; + } + bounds(Number::Decimal, *actual, constraints) + } + PropertyValue::Quantity { .. } => Verdict::Inapplicable( + NotEvaluatedReason::IncompleteEvidence, + "comparing a quantity needs its unit".into(), + ), + PropertyValue::Null => invalid("null has no value to compare"), + } +} + +fn text_verdict(text: &str, constraints: &Constraints<'_>) -> Verdict { + if constraints.has_bounds() { + return invalid("text takes no numeric bounds"); + } + if !constraints.values.is_empty() && !constraints.values.iter().any(|value| value == text) { + return Verdict::Fails(format!("is {text:?}, not one of the required values")); + } + if !constraints.patterns.is_empty() { + let mut any = false; + for pattern in constraints.patterns { + match xsd_pattern::compile(pattern) { + Ok(regex) => any |= regex.is_match(text), + Err(error) => return invalid(format!("pattern {pattern:?}: {error}")), + } + } + if !any { + return Verdict::Fails(format!("is {text:?}, which matches no required pattern")); + } + } + let length = i64::try_from(text.chars().count()).unwrap_or(i64::MAX); + let fails_length = constraints + .length + .is_some_and(|expected| length != expected) + || constraints.min_length.is_some_and(|min| length < min) + || constraints.max_length.is_some_and(|max| length > max); + if fails_length { + return Verdict::Fails(format!( + "is {length} characters long, outside the required length" + )); + } + Verdict::Meets +} + +/// Whether the value equals any literal; no literals constrain nothing. +fn one_of( + literals: &[String], + equals: impl Fn(&str) -> Result, + shown: &str, +) -> Verdict { + if literals.is_empty() { + return Verdict::Meets; + } + let mut any = false; + for literal in literals { + match equals(literal) { + Ok(equal) => any |= equal, + Err(message) => return invalid(message), + } + } + if any { + Verdict::Meets + } else { + Verdict::Fails(format!("is {shown}, not one of the required values")) + } +} + +#[derive(Clone, Copy)] +enum Number { + Integer(i64), + Decimal, +} + +/// A range facet: its literal, the ordering it accepts, and its symbol. +type Bound<'r> = ( + Option<&'r str>, + fn(std::cmp::Ordering) -> bool, + &'static str, +); + +/// Range facets, compared exactly: IDS applies no tolerance to ranges. +fn bounds(number: Number, actual: f64, constraints: &Constraints<'_>) -> Verdict { + let checks: [Bound<'_>; 4] = [ + (constraints.min_inclusive, std::cmp::Ordering::is_ge, ">="), + (constraints.max_inclusive, std::cmp::Ordering::is_le, "<="), + (constraints.min_exclusive, std::cmp::Ordering::is_gt, ">"), + (constraints.max_exclusive, std::cmp::Ordering::is_lt, "<"), + ]; + for (bound, holds, symbol) in checks { + let Some(bound) = bound else { continue }; + let ordering = match (number, parse_integer(bound)) { + // Integer against integer compares exactly, beyond 2^53 too. + (Number::Integer(actual), Some(bound)) => Some(actual.cmp(&bound)), + _ => parse_double(bound).and_then(|bound| actual.partial_cmp(&bound)), + }; + if ordering.is_none() && parse_double(bound).is_none() { + return invalid(format!("{bound:?} is not a number literal")); + } + // An unordered pair (NaN) meets no bound. + if !ordering.is_some_and(holds) { + return Verdict::Fails(format!("is {actual}, not {symbol} {bound}")); + } + } + Verdict::Meets +} + +/// `x == v` in IDS: `v - |v|ε - ε <= x <= v + |v|ε + ε`. +/// +/// The IDS tolerance note writes strict inequalities, but the buildingSMART +/// test cases pass a value exactly on either boundary and fail one just past +/// it, so the boundaries are included. A boundary written in decimal is +/// rarely a binary double, so the comparison also allows a few ulps of +/// rounding (`1e-15` relative), far below the `1e-7` steps those cases test. +fn within_tolerance(actual: f64, expected: f64) -> bool { + let margin = expected.abs() * EPSILON + EPSILON; + let rounding = (expected.abs() + margin) * 1.0e-15; + expected - margin - rounding <= actual && actual <= expected + margin + rounding +} + +/// The `xs:integer` lexical form: an optional sign and digits. +fn parse_integer(literal: &str) -> Option { + let digits = literal.strip_prefix(['+', '-']).unwrap_or(literal); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + literal.strip_prefix('+').unwrap_or(literal).parse().ok() +} + +/// The `xs:double` lexical form, including `INF`, `-INF` and `NaN`. +fn parse_double(literal: &str) -> Option { + match literal { + "INF" | "+INF" => return Some(f64::INFINITY), + "-INF" => return Some(f64::NEG_INFINITY), + "NaN" => return Some(f64::NAN), + _ => {} + } + let unsigned = literal.strip_prefix(['+', '-']).unwrap_or(literal); + let (mantissa, exponent) = match unsigned.find(['e', 'E']) { + Some(at) => (&unsigned[..at], Some(&unsigned[at + 1..])), + None => (unsigned, None), + }; + let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, "")); + let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit()); + let mantissa_ok = + digits(whole) && digits(fraction) && !(whole.is_empty() && fraction.is_empty()); + let exponent_ok = exponent.is_none_or(|exponent| { + let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent); + !exponent.is_empty() && digits(exponent) + }); + if mantissa_ok && exponent_ok { + literal.parse().ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::{parse_double, parse_integer, within_tolerance}; + + #[test] + fn number_literals_follow_xml_schema() { + assert_eq!(parse_integer("+42"), Some(42)); + assert_eq!(parse_integer("-7"), Some(-7)); + for bad in ["42.0", "4 2", "", "+", "0x1"] { + assert_eq!(parse_integer(bad), None, "{bad}"); + } + assert_eq!(parse_double("1.2345e3"), Some(1234.5)); + assert_eq!(parse_double("1.2345E3"), Some(1234.5)); + assert_eq!(parse_double(".5"), Some(0.5)); + assert_eq!(parse_double("5."), Some(5.0)); + assert_eq!(parse_double("-INF"), Some(f64::NEG_INFINITY)); + for bad in ["42,3", "123,4.5", "inf", "nan", "e3", ".", "1e", "1.2.3"] { + assert_eq!(parse_double(bad), None, "{bad}"); + } + } + + #[test] + fn equality_uses_the_ids_tolerance() { + assert!(within_tolerance(100_000.1, 100_000.0)); + assert!(!within_tolerance(100_000.2, 100_000.0)); + assert!(within_tolerance(0.000_000_5, 0.0)); + assert!(!within_tolerance(0.000_001_1, 0.0)); + // The boundary itself is equal, as the buildingSMART cases require. + assert!(within_tolerance(0.000_001, 0.0)); + assert!(within_tolerance(99_999.899_999, 100_000.0)); + assert!(!within_tolerance(99_999.899_998_9, 100_000.0)); + assert!(within_tolerance(0.000_000_900_000_1, -0.000_000_1)); + assert!(!within_tolerance(0.000_000_900_000_11, -0.000_000_1)); + assert!(!within_tolerance(-1_000_001.000_001_1, -1_000_000.0)); + assert!(within_tolerance(-1.000_001, -1.0)); + assert!(!within_tolerance(f64::NAN, f64::NAN)); + } +} diff --git a/crates/engine/rules/src/xsd_pattern.rs b/crates/engine/rules/src/xsd_pattern.rs new file mode 100644 index 00000000..9f0d0ff2 --- /dev/null +++ b/crates/engine/rules/src/xsd_pattern.rs @@ -0,0 +1,216 @@ +//! XML Schema regular expressions, compiled to `regex`. +//! +//! An XML Schema pattern matches the whole value, has no anchors (`^` and +//! `$` are ordinary characters) and defines `.`, `\s` and `\w` differently +//! from `regex`. The translation keeps XML Schema meaning exactly; a +//! construct it cannot map exactly (character-class subtraction, `\i`/`\c` +//! name escapes, `\p{Is…}` block escapes) is refused rather than +//! approximated. + +use regex::Regex; + +/// XML Schema `\s`: space, tab, line feed and carriage return only. +const SPACE: &str = r"\x20\t\n\r"; +const SPACE_CLASS: &str = r"[\x20\t\n\r]"; +const NOT_SPACE_CLASS: &str = r"[^\x20\t\n\r]"; +/// XML Schema `\w`: anything but punctuation, separators and "other". +const NOT_WORD: &str = r"\p{P}\p{Z}\p{C}"; +const WORD_CLASS: &str = r"[^\p{P}\p{Z}\p{C}]"; +const NOT_WORD_CLASS: &str = r"[\p{P}\p{Z}\p{C}]"; + +/// Compiles an XML Schema pattern into a whole-value matcher. +/// +/// # Errors +/// +/// Returns a description of the construct that cannot be translated exactly, +/// or of the syntax error. +pub(crate) fn compile(pattern: &str) -> Result { + let mut out = String::with_capacity(pattern.len() + 8); + let mut chars = pattern.chars().peekable(); + let mut in_class = false; + let mut previous_class_char: Option = None; + while let Some(c) = chars.next() { + if in_class { + match c { + ']' => { + in_class = false; + out.push(']'); + } + '\\' => { + let escaped = chars.next().ok_or("pattern ends with a backslash")?; + match escaped { + 's' => out.push_str(SPACE), + 'S' => out.push_str(NOT_SPACE_CLASS), + 'w' => out.push_str(WORD_CLASS), + 'W' => out.push_str(NOT_WORD), + other => push_escape(&mut out, other, &mut chars)?, + } + } + '-' if chars.peek() == Some(&'[') => { + return Err("character-class subtraction is not supported".into()); + } + '-' if previous_class_char == Some('-') => { + return Err("`--` in a character class is not XML Schema syntax".into()); + } + '[' => return Err("an unescaped `[` inside a character class".into()), + // Set operators in `regex` classes; literals in XML Schema. + '&' | '~' => { + out.push('\\'); + out.push(c); + } + other => out.push(other), + } + previous_class_char = Some(c); + continue; + } + match c { + '.' => out.push_str(r"[^\n\r]"), + '^' | '$' => { + out.push('\\'); + out.push(c); + } + '[' => { + in_class = true; + previous_class_char = None; + out.push('['); + if chars.peek() == Some(&'^') { + chars.next(); + out.push('^'); + } + } + '(' if chars.peek() == Some(&'?') => { + return Err("`(?` is not XML Schema syntax".into()); + } + '\\' => { + let escaped = chars.next().ok_or("pattern ends with a backslash")?; + match escaped { + 's' => out.push_str(SPACE_CLASS), + 'S' => out.push_str(NOT_SPACE_CLASS), + 'w' => out.push_str(WORD_CLASS), + 'W' => out.push_str(NOT_WORD_CLASS), + other => push_escape(&mut out, other, &mut chars)?, + } + } + other => out.push(other), + } + } + if in_class { + return Err("unterminated character class".into()); + } + Regex::new(&format!(r"\A(?:{out})\z")).map_err(|error| error.to_string()) +} + +/// Escapes that mean the same in both dialects, and `\p{…}` categories. +fn push_escape( + out: &mut String, + escaped: char, + chars: &mut std::iter::Peekable>, +) -> Result<(), String> { + match escaped { + 'n' | 'r' | 't' | 'd' | 'D' | '\\' | '|' | '.' | '-' | '^' | '?' | '*' | '+' | '{' + | '}' | '(' | ')' | '[' | ']' => { + out.push('\\'); + out.push(escaped); + Ok(()) + } + 'p' | 'P' => { + if chars.next() != Some('{') { + return Err(format!("`\\{escaped}` needs a braced name")); + } + let mut name = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some(c) => name.push(c), + None => return Err("unterminated `\\p{`".into()), + } + } + if name.starts_with("Is") { + return Err(format!("block escape `\\p{{{name}}}` is not supported")); + } + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(format!("`{name}` is not a Unicode category")); + } + out.push('\\'); + out.push(escaped); + out.push('{'); + out.push_str(&name); + out.push('}'); + Ok(()) + } + 'i' | 'I' | 'c' | 'C' => Err(format!("name escape `\\{escaped}` is not supported")), + other => Err(format!("`\\{other}` is not an XML Schema escape")), + } +} + +#[cfg(test)] +mod tests { + use super::compile; + + fn matches(pattern: &str, value: &str) -> bool { + compile(pattern) + .unwrap_or_else(|error| panic!("{pattern}: {error}")) + .is_match(value) + } + + #[test] + fn patterns_match_the_whole_value() { + assert!(matches("IFC.*", "IFCWALL")); + assert!(!matches("WALL", "IFCWALL")); + assert!(!matches("IFC", "IFCWALL")); + assert!(matches("", "")); + assert!(matches("a|b", "b")); + assert!(!matches("a|b", "ab")); + } + + #[test] + fn anchors_are_ordinary_characters() { + assert!(matches("^a$", "^a$")); + assert!(!matches("^a$", "a")); + } + + #[test] + fn dot_space_and_word_keep_xml_schema_meaning() { + assert!(!matches(".", "\r")); + assert!(!matches(".", "\n")); + assert!(matches(".", "é")); + // U+00A0 is Unicode whitespace but not XML Schema `\s`. + assert!(!matches(r"\s", "\u{a0}")); + assert!(matches(r"\S", "\u{a0}")); + assert!(matches(r"\s", "\t")); + // `_` is punctuation, so not an XML Schema word character. + assert!(!matches(r"\w", "_")); + assert!(matches(r"\W", "_")); + assert!(matches(r"[\s_]+", " _\t")); + assert!(matches(r"[^\s]+", "ab")); + assert!(!matches(r"[\w]", "-")); + } + + #[test] + fn classes_ranges_categories_and_quantifiers() { + assert!(matches("[A-Z]{2}[0-9]{3}", "EI090")); + assert!(!matches("[A-Z]{2}[0-9]{3}", "EI90")); + assert!(matches(r"\p{Lu}+", "ÄB")); + assert!(matches(r"\d+\.\d+", "4.2")); + assert!(matches("[a&~]+", "&~a")); + assert!(matches(r"\^$", "^$")); + } + + #[test] + fn untranslatable_constructs_are_refused() { + for pattern in [ + "[a-z-[aeiou]]", + r"\i\c*", + r"\p{IsBasicLatin}", + "(?i)a", + "[a--b]", + "[a[b]]", + "[ab", + r"\q", + "a\\", + r"\$", + ] { + assert!(compile(pattern).is_err(), "{pattern}"); + } + } +} diff --git a/crates/engine/rules/tests/properties.rs b/crates/engine/rules/tests/properties.rs index 87574a0d..24d1edfd 100644 --- a/crates/engine/rules/tests/properties.rs +++ b/crates/engine/rules/tests/properties.rs @@ -17,7 +17,7 @@ use axioval_ir::{ RuleSetPackage, SourceId, }; use axioval_rules::{ - BooleanPropertyEquals, PropertyPredicate, PropertyRequired, register_builtins, + BooleanPropertyEquals, PropertyDataType, PropertyPredicate, PropertyRequired, register_builtins, }; fn packages() -> (DefinitionPackage, RuleSetPackage) { @@ -538,3 +538,113 @@ fn boolean_property_equals_without_service_is_not_evaluated() { &axioval_ir::NotEvaluatedReason::MissingService ); } + +fn typed_property( + value: Option, + declared: Option<&str>, + expected: &str, +) -> axioval_engine::CapabilityEvaluation { + let property = value.map(|value| { + let property = exact_property("Pset.Typed", "Code", value); + match declared { + Some(data_type) => property.with_data_type(data_type).unwrap(), + None => property, + } + }); + let project = Project::new(vec![object()]).unwrap(); + let mut services = ServiceRegistry::new(); + services + .register(PropertyResolutionServiceHandle::new(Arc::new( + ExactProperties(property.into_iter().collect(), vec![]), + ))) + .unwrap(); + let rule = CompiledRule { + id: RuleId::new("typed").unwrap(), + capability: "axioval:capability.property-data-type".into(), + severity: RuleSeverity::Error, + selector: Selector::All, + parameters: BTreeMap::from([ + ( + "property".into(), + ParameterValue::PropertyReference { + property_set: Some("Pset.Typed".into()), + property: "Code".into(), + }, + ), + ( + "data_type".into(), + ParameterValue::String { + value: expected.into(), + }, + ), + ]), + }; + PropertyDataType.evaluate( + &RuleContext { + project: &project, + services: &services, + }, + &rule, + ) +} + +#[test] +fn data_type_accepts_a_non_empty_value_of_the_declared_type() { + for (value, declared) in [ + (PropertyValue::String("EI 90".into()), "IFCLABEL"), + (PropertyValue::Boolean(false), "IFCBOOLEAN"), + // STEP type names are case-insensitive. + (PropertyValue::Integer(0), "IfcInteger"), + ] { + let expected = declared.to_ascii_uppercase(); + let evaluation = typed_property(Some(value), Some(declared), &expected); + assert!(evaluation.findings().is_empty(), "{declared}"); + assert!(evaluation.not_evaluated_outcomes().is_empty(), "{declared}"); + } +} + +#[test] +fn data_type_rejects_absence_emptiness_and_another_type() { + for (value, declared, message) in [ + (None, None, "missing required property Code"), + ( + Some(PropertyValue::Null), + None, + "missing required property Code", + ), + ( + Some(PropertyValue::String(" ".into())), + Some("IFCLABEL"), + "missing required property Code", + ), + ( + Some(PropertyValue::String("x".into())), + Some("IFCTEXT"), + "property Code is IFCTEXT, not IFCLABEL", + ), + ] { + let evaluation = typed_property(value, declared, "IFCLABEL"); + assert_eq!(evaluation.findings().len(), 1, "{message}"); + assert_eq!(evaluation.findings()[0].message, message); + assert!(!evaluation.findings()[0].evidence.is_empty(), "{message}"); + assert!(evaluation.not_evaluated_outcomes().is_empty(), "{message}"); + } +} + +#[test] +fn an_unreported_data_type_is_not_evaluated_never_matched() { + let evaluation = typed_property(Some(PropertyValue::String("x".into())), None, "IFCLABEL"); + assert!(evaluation.findings().is_empty()); + assert_eq!(evaluation.not_evaluated_outcomes().len(), 1); +} + +#[test] +fn a_blank_data_type_parameter_is_an_invalid_declaration() { + let evaluation = typed_property( + Some(PropertyValue::String("x".into())), + Some("IFCLABEL"), + " ", + ); + assert!(evaluation.findings().is_empty()); + assert_eq!(evaluation.not_evaluated_outcomes().len(), 1); +} diff --git a/crates/engine/rules/tests/property_value.rs b/crates/engine/rules/tests/property_value.rs new file mode 100644 index 00000000..272b3cb0 --- /dev/null +++ b/crates/engine/rules/tests/property_value.rs @@ -0,0 +1,341 @@ +//! `axioval:capability.property-value`: lexical constraints cast to the value. +#![allow(missing_docs)] + +use std::{collections::BTreeMap, sync::Arc}; + +use axioval_engine::{ + CapabilityEvaluation, CapabilityRegistry, CompiledRule, CompletePropertyAbsenceEvidence, + PropertyRequest, PropertyResolution, PropertyResolutionError, PropertyResolutionService, + PropertyResolutionServiceHandle, ResolvedProperty, RuleCapability, RuleContext, + ServiceRegistry, SourceSnapshot, +}; +use axioval_ir::contract::{ParameterValue, Selector, Severity}; +use axioval_ir::{ + Evidence, NotEvaluatedReason, Object, ObjectId, Project, Property, PropertyValue, + QuantityDimension, RuleId, SourceId, +}; +use axioval_rules::{PropertyValueConstraint, register_builtins}; + +fn source() -> SourceId { + SourceId::new("cad", "native-model").unwrap() +} + +fn object() -> Object { + Object::new(ObjectId::new(source(), "wall-1").unwrap(), "wall") +} + +/// One property `P.Code`, or none; resolution is exact either way. +struct OneProperty(Option, Vec); +impl PropertyResolutionService for OneProperty { + fn source_snapshots(&self) -> &[SourceSnapshot] { + &self.1 + } + fn resolve( + &self, + request: &PropertyRequest, + ) -> Result { + match &self.0 { + Some(property) => Ok(PropertyResolution::Present(ResolvedProperty::try_new( + request.clone(), + property.clone(), + )?)), + None => Ok(PropertyResolution::Absent( + CompletePropertyAbsenceEvidence::try_new( + request.clone(), + Evidence::exact(source(), "complete native property table"), + ) + .unwrap(), + )), + } + } +} + +enum Outcome { + Meets, + Fails(String), + NotEvaluated(NotEvaluatedReason), +} + +fn check( + value: Option, + data_type: Option<&str>, + parameters: &[(&str, ParameterValue)], +) -> Outcome { + let property = value.map(|value| { + let property = Property::new("P", "Code", value) + .unwrap() + .with_evidence(Evidence::exact(source(), "native P.Code")); + match data_type { + Some(data_type) => property.with_data_type(data_type).unwrap(), + None => property, + } + }); + let project = Project::new(vec![object()]).unwrap(); + let mut services = ServiceRegistry::new(); + services + .register(PropertyResolutionServiceHandle::new(Arc::new(OneProperty( + property, + vec![], + )))) + .unwrap(); + let mut all = BTreeMap::from([( + "property".to_owned(), + ParameterValue::PropertyReference { + property_set: Some("P".into()), + property: "Code".into(), + }, + )]); + for (name, value) in parameters { + all.insert((*name).to_owned(), value.clone()); + } + let rule = CompiledRule { + id: RuleId::new("value").unwrap(), + capability: "axioval:capability.property-value".into(), + severity: Severity::Error, + selector: Selector::All, + parameters: all, + }; + outcome(&PropertyValueConstraint.evaluate( + &RuleContext { + project: &project, + services: &services, + }, + &rule, + )) +} + +fn outcome(evaluation: &CapabilityEvaluation) -> Outcome { + match (evaluation.findings(), evaluation.not_evaluated_outcomes()) { + ([], []) => Outcome::Meets, + ([finding], []) => { + assert!(!finding.evidence.is_empty(), "{}", finding.message); + Outcome::Fails(finding.message.clone()) + } + ([], [outcome]) => Outcome::NotEvaluated(outcome.reason().clone()), + (findings, outcomes) => panic!("{findings:?} {outcomes:?}"), + } +} + +fn values(literals: &[&str]) -> (&'static str, ParameterValue) { + ( + "values", + ParameterValue::StringList { + value: literals + .iter() + .map(|literal| (*literal).to_owned()) + .collect(), + }, + ) +} + +fn text(name: &'static str, value: &str) -> (&'static str, ParameterValue) { + ( + name, + ParameterValue::String { + value: value.into(), + }, + ) +} + +fn optional() -> (&'static str, ParameterValue) { + ("optional", ParameterValue::Boolean { value: true }) +} + +#[allow(clippy::unnecessary_wraps)] // feeds the `Option` argument of `check` +fn string(value: &str) -> Option { + Some(PropertyValue::String(value.into())) +} + +fn meets(outcome: &Outcome) -> bool { + matches!(outcome, Outcome::Meets) +} + +fn fails(outcome: &Outcome) -> bool { + matches!(outcome, Outcome::Fails(_)) +} + +fn invalid(outcome: &Outcome) -> bool { + matches!( + outcome, + Outcome::NotEvaluated(NotEvaluatedReason::InvalidDeclaration) + ) +} + +#[test] +fn text_compares_exactly_and_case_sensitively() { + assert!(meets(&check(string("Bar"), None, &[values(&["Bar"])]))); + assert!(fails(&check(string("bar"), None, &[values(&["Bar"])]))); + assert!(meets(&check(string("1"), None, &[values(&["1"])]))); + assert!(meets(&check(string("B"), None, &[values(&["A", "B"])]))); +} + +#[test] +fn patterns_and_lengths_apply_to_text() { + let pattern = ( + "patterns", + ParameterValue::StringList { + value: vec!["EI [0-9]+".into()], + }, + ); + assert!(meets(&check(string("EI 90"), None, &[pattern.clone()]))); + assert!(fails(&check(string("REI 90"), None, &[pattern]))); + let length = ("max_length", ParameterValue::Integer { value: 3 }); + assert!(meets(&check(string("äöü"), None, &[length.clone()]))); + assert!(fails(&check(string("abcd"), None, &[length]))); + let broken = ( + "patterns", + ParameterValue::StringList { + value: vec!["[a-z-[aeiou]]".into()], + }, + ); + assert!(invalid(&check(string("b"), None, &[broken]))); + assert!(invalid(&check( + string("b"), + None, + &[text("min_inclusive", "1")] + ))); +} + +#[test] +fn booleans_take_true_false_one_and_zero() { + let value = Some(PropertyValue::Boolean(false)); + assert!(meets(&check(value.clone(), None, &[values(&["false"])]))); + assert!(meets(&check(value.clone(), None, &[values(&["0"])]))); + assert!(fails(&check(value.clone(), None, &[values(&["true"])]))); + assert!(invalid(&check(value, None, &[values(&["FALSE"])]))); +} + +#[test] +fn integers_take_integer_literals_and_numeric_bounds() { + let value = Some(PropertyValue::Integer(42)); + assert!(meets(&check(value.clone(), None, &[values(&["42"])]))); + assert!(meets(&check(value.clone(), None, &[values(&["+42"])]))); + assert!(invalid(&check(value.clone(), None, &[values(&["42.3"])]))); + assert!(meets(&check( + value.clone(), + None, + &[text("min_inclusive", "42")] + ))); + assert!(fails(&check( + value.clone(), + None, + &[text("max_exclusive", "42")] + ))); + assert!(meets(&check( + value.clone(), + None, + &[text("max_exclusive", "4.25e1")] + ))); + assert!(invalid(&check( + value, + None, + &[text("min_inclusive", "4,2")] + ))); +} + +#[test] +fn decimals_equal_within_tolerance_and_bound_exactly() { + let value = Some(PropertyValue::Decimal(42.0)); + assert!(meets(&check(value.clone(), None, &[values(&["42"])]))); + assert!(meets(&check(value.clone(), None, &[values(&["42.00001"])]))); + assert!(fails(&check(value.clone(), None, &[values(&["42.001"])]))); + assert!(meets(&check( + Some(PropertyValue::Decimal(1234.5)), + None, + &[values(&["1.2345E3"])] + ))); + // Ranges take no tolerance. + let one = |value: f64, bound: (&'static str, ParameterValue)| { + check(Some(PropertyValue::Decimal(value)), None, &[bound]) + }; + assert!(fails(&one(0.999_999_99, text("min_inclusive", "1.0")))); + assert!(meets(&one(1.0, text("min_inclusive", "1.0")))); + assert!(fails(&one(1.0, text("min_exclusive", "1.0")))); + assert!(meets(&one(1.000_000_01, text("min_exclusive", "1.0")))); + assert!(invalid(&check(value, None, &[values(&["42,3"])]))); +} + +#[test] +fn quantities_need_units_and_are_not_evaluated() { + let quantity = Some(PropertyValue::Quantity { + value: 2.5, + dimension: QuantityDimension::Length, + }); + assert!(matches!( + check(quantity, None, &[values(&["2.5"])]), + Outcome::NotEvaluated(NotEvaluatedReason::IncompleteEvidence) + )); +} + +#[test] +fn required_values_treat_absence_null_and_blank_as_missing() { + for value in [None, Some(PropertyValue::Null), string(" ")] { + let Outcome::Fails(message) = check(value, None, &[values(&["x"])]) else { + panic!("must fail") + }; + assert_eq!(message, "missing required property Code"); + } +} + +#[test] +fn optional_values_pass_when_absent_or_null_and_check_what_is_there() { + assert!(meets(&check(None, None, &[values(&["x"]), optional()]))); + assert!(meets(&check( + Some(PropertyValue::Null), + None, + &[values(&["x"]), optional()] + ))); + // An empty string is present, and not "x". + assert!(fails(&check( + string(""), + None, + &[values(&["x"]), optional()] + ))); + assert!(meets(&check( + string("x"), + None, + &[values(&["x"]), optional()] + ))); + // A type alone is a constraint. + assert!(meets(&check( + None, + None, + &[text("data_type", "IFCLABEL"), optional()] + ))); + assert!(fails(&check( + string("x"), + Some("IFCTEXT"), + &[text("data_type", "IFCLABEL"), optional()] + ))); +} + +#[test] +fn the_declared_type_is_checked_before_the_value() { + let label = [text("data_type", "IFCLABEL"), values(&["x"])]; + assert!(meets(&check(string("x"), Some("IFCLABEL"), &label))); + let Outcome::Fails(message) = check(string("x"), Some("IFCTEXT"), &label) else { + panic!("must fail") + }; + assert_eq!(message, "property Code is IFCTEXT, not IFCLABEL"); + assert!(matches!( + check(string("x"), None, &label), + Outcome::NotEvaluated(NotEvaluatedReason::IncompleteEvidence) + )); +} + +#[test] +fn a_rule_without_constraints_is_an_invalid_declaration() { + assert!(invalid(&check(string("x"), None, &[]))); + assert!(invalid(&check(string("x"), None, &[optional()]))); + assert!(invalid(&check( + string("x"), + None, + &[("length", ParameterValue::Integer { value: -1 })] + ))); +} + +#[test] +fn the_capability_is_registered() { + let registry = register_builtins(CapabilityRegistry::new()).unwrap(); + assert!(registry.get("axioval:capability.property-value").is_some()); +} diff --git a/crates/sources/semantic/ifc/AGENTS.md b/crates/sources/semantic/ifc/AGENTS.md index a445c93e..ca706f4f 100644 --- a/crates/sources/semantic/ifc/AGENTS.md +++ b/crates/sources/semantic/ifc/AGENTS.md @@ -7,6 +7,7 @@ IFC source contracts, conformance doubles, and the production IFC2X3/IFC4 STEP e - `src/relationships.rs` is the exact relationship-selection service. Relationship identities are entity names in the source's own release; end slots come from that release's schema (`ends_of`), never a hand-written table. Every answer carries a `relationship-scan` completeness locator. - `src/integrity.rs` reports source irregularities through the same `read_instance` reader as selection. Keep them sharing one reader so a warning and a refusal always describe the same instances. - Absent required ends (`$`) are recorded, not fatal: strict requests refuse, `AbsentEndPolicy::Skip` answers and cites each one, and integrity reports them as warnings. "Required" is per release: `IfcRelSpaceBoundary.RelatedBuildingElement` is optional in IFC2X3 and required in IFC4. Dangling or wrongly shaped ends stay hard refusals and integrity errors. Do not widen skip to cover those. +- `src/unread.rs` indexes the property-set definitions `exact_property` skips (quantity sets, predefined property sets). The property service refuses an absence that one of them could hold. Keep it an index of names only; resolving quantities belongs upstream, and the index goes when openbimrs/ifc#66 lands. - `src/identity.rs` reads every `IfcRoot` GlobalId once per session. The same scan decides which objects carry the `ifc-globalid` alias and which integrity warnings are raised, so a missing alias always has exactly one warning. Never attach a GlobalId that is invalid or shared, and keep the `to_uuid`/`from_uuid` round trip until openbimrs/ifc#62 lands. - `src/classifications.rs` maps `ifc-classification` answers onto `ClassificationService`. Never read classification slots here: release-specific names, notations and hierarchy rules are the upstream crate's. An assignment with no stated system stays `system: None`; never infer one from names or locations. - Integrity cardinality warnings (`CONTAINED_TWICE`, `ZONE_MEMBER_NOT_SPATIAL`) come from `ifc-systems` anomalies; do not re-derive them from raw relationships. diff --git a/crates/sources/semantic/ifc/src/ifc.rs b/crates/sources/semantic/ifc/src/ifc.rs index 3fdc283e..3fb6decd 100644 --- a/crates/sources/semantic/ifc/src/ifc.rs +++ b/crates/sources/semantic/ifc/src/ifc.rs @@ -23,6 +23,7 @@ use crate::identity::{GlobalIds, IFC_GLOBAL_ID}; use crate::integrity::IfcIntegrity; use crate::relationships::IfcRelationshipService; use crate::release::Release; +use crate::unread::UnreadDefinitions; /// Production IFC import/session construction failure. #[derive(Debug, Error, Eq, PartialEq)] @@ -79,8 +80,10 @@ impl TypeHierarchyService for IfcTypeHierarchy { #[derive(Clone)] struct IfcPropertyService { + release: Release, model: Arc, snapshots: Arc<[SourceSnapshot]>, + unread: Arc, } impl IfcPropertyService { @@ -101,6 +104,33 @@ impl IfcPropertyService { } } +impl IfcPropertyService { + /// Whether a value of the declared defined type maps onto a property + /// value without loss. + /// + /// Decided by the type's base in this release's schema, so every + /// string-based type (`IfcLabel`, `IfcDate`, `IfcDuration`, ...) and every + /// integer-based one (`IfcTimeStamp`, `IfcCountMeasure` as written) is + /// carried with its declared type. Real-valued measures stay refused: their + /// number means nothing without the unit context, which is not read yet. + /// `IFCREAL` and dimensionless `NUMBER` types have no unit. + fn carries_exactly(&self, value: &ExactValue, value_type: &str) -> bool { + let base = self + .release + .schema + .resolve_defined(value_type) + .to_ascii_uppercase(); + let base = base.split('(').next().unwrap_or_default().trim(); + match value { + ExactValue::Bool(_) => base == "BOOLEAN", + ExactValue::Integer(_) => base == "INTEGER" || base == "NUMBER", + ExactValue::Real(_) => value_type.eq_ignore_ascii_case("IFCREAL") || base == "NUMBER", + ExactValue::Text(_) => base == "STRING", + _ => false, + } + } +} + impl PropertyResolutionService for IfcPropertyService { fn source_snapshots(&self) -> &[SourceSnapshot] { &self.snapshots @@ -131,20 +161,7 @@ impl PropertyResolutionService for IfcPropertyService { } let compatible_type = match (&exact.value, exact.value_type.as_deref()) { (ExactValue::Null, None) => true, - (ExactValue::Bool(_), Some(value_type)) => { - value_type.eq_ignore_ascii_case("IFCBOOLEAN") - } - (ExactValue::Integer(_), Some(value_type)) => { - value_type.eq_ignore_ascii_case("IFCINTEGER") - } - (ExactValue::Real(_), Some(value_type)) => { - value_type.eq_ignore_ascii_case("IFCREAL") - } - (ExactValue::Text(_), Some(value_type)) => { - ["IFCTEXT", "IFCLABEL", "IFCIDENTIFIER"] - .iter() - .any(|candidate| value_type.eq_ignore_ascii_case(candidate)) - } + (value, Some(value_type)) => self.carries_exactly(value, value_type), _ => false, }; if !compatible_type { @@ -158,34 +175,50 @@ impl PropertyResolutionService for IfcPropertyService { ExactValue::Text(value) => PropertyValue::String(value.to_string()), _ => return Err(PropertyResolutionError::InexactEvidence), }; - let property = + let mut property = Property::new(exact.property_set.as_ref(), request.property(), value) - .map_err(|_| PropertyResolutionError::InvalidRequest)? - .with_evidence(Evidence::exact( - self.snapshots[0].source().clone(), - self.locator(format_args!( - "{provenance}:{}/{}", - exact.set_id, exact.property_id - )), - )); + .map_err(|_| PropertyResolutionError::InvalidRequest)?; + // STEP writes type names upper case; report them that way + // whatever case the file used. + if let Some(value_type) = exact.value_type.as_deref() { + property = property + .with_data_type(value_type.to_ascii_uppercase()) + .map_err(|_| PropertyResolutionError::InexactEvidence)?; + } + let property = property.with_evidence(Evidence::exact( + self.snapshots[0].source().clone(), + self.locator(format_args!( + "{provenance}:{}/{}", + exact.set_id, exact.property_id + )), + )); Ok(PropertyResolution::Present(ResolvedProperty::try_new( request.clone(), property, )?)) } - Ok(ExactResolution::Absent) => Ok(PropertyResolution::Absent( - CompletePropertyAbsenceEvidence::try_new( - request.clone(), - Evidence::exact( - self.snapshots[0].source().clone(), - self.locator(format_args!( - "absence:{object}:{}:{}", - request.property_set().unwrap_or("*"), - request.property() - )), - ), - )?, - )), + Ok(ExactResolution::Absent) => { + // Upstream proves absence from property sets only. + if let Some(reason) = self + .unread + .obscures(request.property_set(), request.property()) + { + return Err(PropertyResolutionError::Incomplete(reason)); + } + Ok(PropertyResolution::Absent( + CompletePropertyAbsenceEvidence::try_new( + request.clone(), + Evidence::exact( + self.snapshots[0].source().clone(), + self.locator(format_args!( + "absence:{object}:{}:{}", + request.property_set().unwrap_or("*"), + request.property() + )), + ), + )?, + )) + } Ok(_) => Err(PropertyResolutionError::InexactEvidence), Err(error) => Err(map_resolution_error(&error)), } @@ -273,8 +306,10 @@ pub fn import_ifc_session( let snapshots: Arc<[SourceSnapshot]> = Arc::from([snapshot.clone()]); let model = Arc::new(model); let service = PropertyResolutionServiceHandle::new(Arc::new(IfcPropertyService { + release, model: model.clone(), snapshots: snapshots.clone(), + unread: Arc::new(UnreadDefinitions::read(release, &model)), })); let integrity = SourceIntegrityServiceHandle::new(Arc::new(IfcIntegrity::new( release, diff --git a/crates/sources/semantic/ifc/src/lib.rs b/crates/sources/semantic/ifc/src/lib.rs index e405d356..ced96f39 100644 --- a/crates/sources/semantic/ifc/src/lib.rs +++ b/crates/sources/semantic/ifc/src/lib.rs @@ -12,6 +12,7 @@ mod ifc; mod integrity; mod relationships; mod release; +mod unread; pub use identity::IFC_GLOBAL_ID; pub use ifc::{IfcSessionError, import_ifc_session}; pub use integrity::{ diff --git a/crates/sources/semantic/ifc/src/unread.rs b/crates/sources/semantic/ifc/src/unread.rs new file mode 100644 index 00000000..e4d3b25b --- /dev/null +++ b/crates/sources/semantic/ifc/src/unread.rs @@ -0,0 +1,142 @@ +//! Property-set definitions exact resolution does not read. +//! +//! `ifc-properties::exact_property` resolves members of `IfcPropertySet` and +//! skips every other `IfcPropertySetDefinition`: quantity sets +//! (`IfcElementQuantity`) and predefined property sets such as +//! `IfcDoorLiningProperties`. Its `Absent` is therefore "in no property set", +//! which is narrower than the complete absence the engine's evidence claims: +//! a quantity named `Foo` in a set named `Foo_Bar` would be reported absent +//! from `Foo_Bar`. +//! +//! This index lets the property service refuse that claim. It records, once +//! per session, the name of every skipped definition and every name a +//! member of one could be looked up by: quantity names (nested complex +//! quantities included) and the attributes a predefined set declares. It is +//! model-wide, not per object, so it can only turn an absence into "not +//! evaluated", never hide a present value. +//! +//! A workaround until upstream resolves or refuses these definitions itself +//! (openbimrs/ifc#66); remove it then. + +use std::collections::BTreeSet; + +use ifc_model::{EntityId, Model, Value}; + +use crate::release::Release; + +/// Names that an absence claim must not cover. +#[derive(Debug, Default)] +pub(crate) struct UnreadDefinitions { + /// `Name` of every skipped definition. + sets: BTreeSet, + /// Every member name a skipped definition could answer a lookup with. + members: BTreeSet, +} + +impl UnreadDefinitions { + pub(crate) fn read(release: Release, model: &Model) -> Self { + let schema = release.schema; + let inherited: BTreeSet<&str> = schema + .attribute_names("IfcPropertySetDefinition") + .into_iter() + .collect(); + let mut unread = Self::default(); + for (_, entity) in model.iter() { + let kind = entity.type_name.as_ref(); + if !schema.is_a(kind, "IFCPROPERTYSETDEFINITION") || schema.is_a(kind, "IFCPROPERTYSET") + { + continue; + } + let names = schema.attribute_names(kind); + if let Some(name) = + position(&names, "Name").and_then(|slot| text(entity.attribute(slot))) + { + unread.sets.insert(name); + } + if schema.is_a(kind, "IFCELEMENTQUANTITY") { + let quantities = + position(&names, "Quantities").and_then(|slot| entity.attribute(slot)); + unread.quantities(release, model, quantities, &mut BTreeSet::new()); + } else { + // A predefined set's members are its own attributes. + unread.members.extend( + names + .iter() + .filter(|name| !inherited.contains(*name)) + .map(|name| (*name).to_owned()), + ); + } + } + unread + } + + /// Records the names of `quantities`, descending into complex ones. + fn quantities( + &mut self, + release: Release, + model: &Model, + quantities: Option<&Value>, + seen: &mut BTreeSet, + ) { + let schema = release.schema; + for id in references(quantities) { + // A cycle is malformed, and exact resolution never reads it. + if !seen.insert(id) { + continue; + } + let Some(quantity) = model.get(id) else { + continue; + }; + let kind = quantity.type_name.as_ref(); + let names = schema.attribute_names(kind); + let Some(name_slot) = position(&names, "Name") else { + continue; + }; + if let Some(name) = text(quantity.attribute(name_slot)) { + self.members.insert(name); + } + if schema.is_a(kind, "IFCPHYSICALCOMPLEXQUANTITY") { + if let Some(slot) = position(&names, "HasQuantities") { + self.quantities(release, model, quantity.attribute(slot), seen); + } + } + } + } + + /// Why an absence of `property` from `set` is not proven, if it is not. + pub(crate) fn obscures(&self, set: Option<&str>, property: &str) -> Option { + match set { + Some(set) if self.sets.contains(set) => Some(format!( + "`{set}` is a quantity or predefined property set, which exact resolution does not read" + )), + None if self.members.contains(property) => Some(format!( + "`{property}` names a member of a quantity or predefined property set, which exact resolution does not read" + )), + _ => None, + } + } +} + +fn position(names: &[&str], wanted: &str) -> Option { + names.iter().position(|name| *name == wanted) +} + +fn text(value: Option<&Value>) -> Option { + match value? { + Value::Text(text) => Some(text.to_string()), + _ => None, + } +} + +fn references(value: Option<&Value>) -> Vec { + match value { + Some(Value::List(items)) => items + .iter() + .filter_map(|item| match item { + Value::Ref(id) => Some(*id), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/crates/sources/semantic/ifc/tests/defined_types.rs b/crates/sources/semantic/ifc/tests/defined_types.rs new file mode 100644 index 00000000..2063c0c2 --- /dev/null +++ b/crates/sources/semantic/ifc/tests/defined_types.rs @@ -0,0 +1,77 @@ +//! Values of defined types are carried when their base maps without loss. +#![allow(missing_docs)] + +use axioval_engine::{ + PropertyRequest, PropertyResolution, PropertyResolutionError, PropertyResolutionServiceHandle, +}; +use axioval_ifc::import_ifc_session; +use axioval_ir::{ObjectId, PropertyValue, SourceId}; + +const IFC4: &str = "ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('n','t',(''),(''),'p','o','a'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$,$); +#2=IFCPROPERTYSINGLEVALUE('Date',$,IFCDATE('2022-01-01'),$); +#3=IFCPROPERTYSINGLEVALUE('Count',$,IFCCOUNTMEASURE(3),$); +#4=IFCPROPERTYSINGLEVALUE('Stamp',$,IFCTIMESTAMP(1700000000),$); +#5=IFCPROPERTYSINGLEVALUE('Length',$,IFCLENGTHMEASURE(2.5),$); +#6=IFCPROPERTYSINGLEVALUE('Duration',$,IFCDURATION('P1D'),$); +#7=IFCPROPERTYSET('0000000000000000000002',$,'P',$,(#2,#3,#4,#5,#6)); +#8=IFCRELDEFINESBYPROPERTIES('0000000000000000000003',$,$,$,(#1),#7); +ENDSEC; +END-ISO-10303-21; +"; + +fn resolve(name: &str) -> Result { + let session = import_ifc_session("model.ifc", IFC4.as_bytes()).unwrap(); + let request = PropertyRequest::try_new( + ObjectId::new(SourceId::new("ifc-step", "model.ifc").unwrap(), "#1").unwrap(), + Some("P".to_owned()), + name, + ) + .unwrap(); + session + .service::() + .unwrap() + .resolve(&request) +} + +#[test] +fn string_and_integer_based_types_are_carried_with_their_declared_type() { + for (name, value, data_type) in [ + ( + "Date", + PropertyValue::String("2022-01-01".into()), + "IFCDATE", + ), + ( + "Duration", + PropertyValue::String("P1D".into()), + "IFCDURATION", + ), + ("Count", PropertyValue::Integer(3), "IFCCOUNTMEASURE"), + ( + "Stamp", + PropertyValue::Integer(1_700_000_000), + "IFCTIMESTAMP", + ), + ] { + let Ok(PropertyResolution::Present(resolved)) = resolve(name) else { + panic!("{name}: {:?}", resolve(name)); + }; + assert_eq!(resolved.property().value, value, "{name}"); + assert_eq!(resolved.property().data_type(), Some(data_type), "{name}"); + } +} + +#[test] +fn a_measure_with_a_unit_context_stays_refused() { + assert!(matches!( + resolve("Length"), + Err(PropertyResolutionError::InexactEvidence) + )); +} diff --git a/crates/sources/semantic/ifc/tests/ifc_session.rs b/crates/sources/semantic/ifc/tests/ifc_session.rs index 0da786d4..c2a70820 100644 --- a/crates/sources/semantic/ifc/tests/ifc_session.rs +++ b/crates/sources/semantic/ifc/tests/ifc_session.rs @@ -41,6 +41,8 @@ fn strict_ifc_bytes_build_an_exact_direct_property_session() { panic!("flag must be present"); }; assert_eq!(flag.property().value, PropertyValue::Boolean(true)); + // The file writes `ifcboolean`; the declared type is reported upper case. + assert_eq!(flag.property().data_type(), Some("IFCBOOLEAN")); let flag_evidence = flag.property().evidence.as_ref().unwrap(); assert_eq!(flag_evidence.source, request("Flag").object_id().source); assert!(flag_evidence.locator.contains(&fingerprint)); @@ -53,6 +55,7 @@ fn strict_ifc_bytes_build_an_exact_direct_property_session() { big.property().value, PropertyValue::Integer(9_007_199_254_740_993) ); + assert_eq!(big.property().data_type(), Some("IFCINTEGER")); let PropertyResolution::Absent(absence) = properties.resolve(&request("Missing")).unwrap() else { diff --git a/crates/sources/semantic/ifc/tests/unread_definitions.rs b/crates/sources/semantic/ifc/tests/unread_definitions.rs new file mode 100644 index 00000000..ae53838e --- /dev/null +++ b/crates/sources/semantic/ifc/tests/unread_definitions.rs @@ -0,0 +1,137 @@ +//! Absence is proven only over definitions the exact resolver reads. +//! +//! `ifc-properties` resolves `IfcPropertySet` members and skips every other +//! `IfcPropertySetDefinition`: quantity sets and predefined property sets. Its +//! `Absent` therefore means "in no property set", and must not become exact +//! absence evidence while a skipped definition could hold the property. + +use axioval_engine::{ + PropertyRequest, PropertyResolution, PropertyResolutionError, PropertyResolutionServiceHandle, +}; +use axioval_ifc::import_ifc_session; +use axioval_ir::{ObjectId, SourceId}; + +/// A wall with a quantity set (one quantity nested in a complex quantity) +/// and a property set, and a door with a predefined lining property set. +const IFC4: &[u8] = b"ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('n','t',(''),(''),'p','o','a'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$,$); +#2=IFCQUANTITYLENGTH('Foo',$,$,42.,$); +#3=IFCPHYSICALCOMPLEXQUANTITY('Layer',$,(#4),'layer',$,$); +#4=IFCQUANTITYLENGTH('Inner',$,$,1.,$); +#5=IFCELEMENTQUANTITY('0000000000000000000002',$,'Foo_Bar',$,$,(#2,#3)); +#6=IFCRELDEFINESBYPROPERTIES('0000000000000000000003',$,$,$,(#1),#5); +#7=IFCPROPERTYSINGLEVALUE('Other',$,IFCLABEL('x'),$); +#8=IFCPROPERTYSET('0000000000000000000004',$,'Pset_Test',$,(#7)); +#9=IFCRELDEFINESBYPROPERTIES('0000000000000000000005',$,$,$,(#1),#8); +#10=IFCDOOR('0000000000000000000006',$,$,$,$,$,$,$,$,$,$,$,$); +#11=IFCDOORLININGPROPERTIES('0000000000000000000007',$,'Lining',$,0.1,$,$,$,$,$,$,$,$,$,$,$,$); +#12=IFCRELDEFINESBYPROPERTIES('0000000000000000000008',$,$,$,(#10),#11); +ENDSEC; +END-ISO-10303-21; +"; + +fn resolve( + object: &str, + set: Option<&str>, + name: &str, +) -> Result { + let session = import_ifc_session("model.ifc", IFC4).unwrap(); + let request = PropertyRequest::try_new( + ObjectId::new(SourceId::new("ifc-step", "model.ifc").unwrap(), object).unwrap(), + set.map(ToOwned::to_owned), + name, + ) + .unwrap(); + session + .service::() + .unwrap() + .resolve(&request) +} + +#[test] +fn a_named_set_that_is_not_a_property_set_proves_nothing_absent() { + for (object, set, name) in [ + ("#1", "Foo_Bar", "Foo"), + ("#1", "Foo_Bar", "Anything"), + ("#10", "Lining", "LiningDepth"), + ] { + let result = resolve(object, Some(set), name); + assert!( + matches!(result, Err(PropertyResolutionError::Incomplete(ref message)) if message.contains(set)), + "{object} {set}.{name}: {result:?}" + ); + } +} + +#[test] +fn an_unqualified_name_held_by_an_unread_definition_proves_nothing_absent() { + // A quantity, one nested in a complex quantity, the complex quantity + // itself, and a predefined set's attribute. + for name in ["Foo", "Inner", "Layer", "LiningDepth"] { + let result = resolve("#1", None, name); + assert!( + matches!(result, Err(PropertyResolutionError::Incomplete(_))), + "{name}: {result:?}" + ); + } +} + +#[test] +fn absence_outside_unread_definitions_stays_exact() { + for (set, name) in [ + (Some("Pset_Test"), "Foo"), + (None, "Nothing"), + (Some("Pset_Other"), "Foo"), + ] { + let result = resolve("#1", set, name); + assert!( + matches!(result, Ok(PropertyResolution::Absent(_))), + "{set:?}.{name}: {result:?}" + ); + } + assert!(matches!( + resolve("#1", Some("Pset_Test"), "Other"), + Ok(PropertyResolution::Present(_)) + )); +} + +#[test] +fn ifc2x3_quantity_sets_are_read_with_ifc2x3_tables() { + const IFC2X3: &[u8] = b"ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('n','t',(''),(''),'p','o','a'); +FILE_SCHEMA(('IFC2X3')); +ENDSEC; +DATA; +#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$); +#2=IFCQUANTITYLENGTH('Width',$,$,0.3); +#3=IFCELEMENTQUANTITY('0000000000000000000002',$,'BaseQuantities',$,$,(#2)); +#4=IFCRELDEFINESBYPROPERTIES('0000000000000000000003',$,$,$,(#1),#3); +ENDSEC; +END-ISO-10303-21; +"; + let session = import_ifc_session("model.ifc", IFC2X3).unwrap(); + let service = session + .service::() + .unwrap(); + for (set, name) in [(Some("BaseQuantities"), "Width"), (None, "Width")] { + let request = PropertyRequest::try_new( + ObjectId::new(SourceId::new("ifc-step", "model.ifc").unwrap(), "#1").unwrap(), + set.map(ToOwned::to_owned), + name, + ) + .unwrap(); + let result = service.resolve(&request); + assert!( + matches!(result, Err(PropertyResolutionError::Incomplete(_))), + "{set:?}.{name}: {result:?}" + ); + } +} diff --git a/docs/src/adapters.md b/docs/src/adapters.md index df13fbb6..ae64d091 100644 --- a/docs/src/adapters.md +++ b/docs/src/adapters.md @@ -13,6 +13,23 @@ snapshot registered by the session; mismatched service composition is rejected. Parser diagnostics, unsupported schemas, malformed traversal, conflicts, and unsupported values fail closed. +A present property reports the IFC type its value was written with (`IFCLABEL`, +`IFCBOOLEAN`, ...) as `Property::data_type`, upper case whatever the file used. +A value is carried when its defined type's base in the file's release maps without +loss: every `STRING`-based type (`IfcLabel`, `IfcDate`, `IfcDuration`, ...), +`INTEGER`- and `NUMBER`-based integers (`IfcTimeStamp`, `IfcCountMeasure`), +`IfcBoolean`, and `IfcReal` or dimensionless `NUMBER` reals. Other real-valued +measures stay refused until unit handling lands. + +Exact absence covers what the resolver reads: `IfcPropertySet` members. +Quantity sets (`IfcElementQuantity`) and predefined property sets +(`IfcDoorLiningProperties` and its kin) are not read, so an absence is refused +as incomplete when the requested set is one of them, or, for a request that +names no set, when one of them has a member of the requested name (a +quantity, a nested quantity, or a predefined set's attribute). The index is +built once per session over the whole file; it can make an answer not +evaluated, never change a present value. + Direct-property completeness does not imply relationship completeness. The IFC session registers an exact relationship-selection service: a relationship identity is the entity name, in the source's own release, of an objectified relationship type (for diff --git a/docs/src/capabilities.md b/docs/src/capabilities.md index b346505d..f21348d9 100644 --- a/docs/src/capabilities.md +++ b/docs/src/capabilities.md @@ -25,6 +25,10 @@ A capability may return findings and not-evaluated outcomes together when only p `axioval:capability.property-exists`, `axioval:capability.property-required`, `axioval:capability.property-value-equals`, and `axioval:capability.property-predicate` resolve values through `PropertyResolutionServiceHandle`. The integer predicate accepts `equal`, `not_equal`, `greater_than`, `greater_or_equal`, `less_than`, and `less_or_equal`; failed predicates retain exact source evidence. A present value must be bound to the complete request—including its source-qualified object identity—and carry exact reviewable evidence. A missing value is conclusive only when the provider returns exact request-bound `CompletePropertyAbsenceEvidence`; service absence, partial extraction, cross-object substitution, mismatched responses, or inexact provenance remain not evaluated. Property selectors use the same resolver, so incomplete applicability data cannot silently skip an object. `property-exists` checks exact presence and intentionally does not reinterpret a present typed value. `property-required` applies the stronger required-value contract: exact absence, `null`, or blank text emits an evidence-backed finding, while booleans, integers, finite numbers/quantities, and nonblank text satisfy it. `property-value-equals` accepts a typed `property` reference and boolean `expected` value; a non-boolean resolution is invalid evidence rather than a pass or violation. +`axioval:capability.property-data-type` takes a `property` reference and a `data_type` string. It applies the `property-required` contract and additionally requires the value's type as the source declares it (`Property::data_type`, for IFC `IFCLABEL`, `IFCBOOLEAN`, ...) to equal `data_type`, compared ASCII case-insensitively. A different declared type is a finding. A present value whose type the source does not report is not evaluated, never taken to match. It is what an IDS property facet with a `dataType` and no value translates to. + +`axioval:capability.property-value` checks a value against lexical constraints cast to the kind of the value the source resolved: `values` (any of), `patterns` (XML Schema regular expressions matching the whole value), `min_inclusive`/`max_inclusive`/`min_exclusive`/`max_exclusive`, `length`/`min_length`/`max_length`, and optionally `data_type`. Text compares exactly and case-sensitively and alone takes patterns and lengths; booleans accept `true`/`1` and `false`/`0`; integers take integer literals; decimals take `xs:double` literals and are equal within `|x - v| <= |v|·1e-6 + 1e-6` (the IDS tolerance, boundaries included), while bounds compare exactly. Without `optional`, absence, `null` and blank text are violations; with it, an absent or `null` property passes and any present value is checked. A literal that cannot be cast, a constraint the value's kind does not take, or a pattern that cannot be translated exactly (class subtraction, `\i`/`\c`, block escapes) makes the object not evaluated (`InvalidDeclaration`); a quantity is not evaluated until units are handled. + `axioval:capability.property-comparison` currently covers exact property-to-property targets with an independent selector-valued candidate scope, checked/shared/related modes, target-side factors, and `each` or `at_least_one` quantifiers. It compares booleans, strings, exact integers, finite decimals, and canonical quantities with matching dimensions. Missing properties emit evidence-backed missing-information findings; incompatible types or unavailable evidence remain not evaluated. Constant targets, `count`, and `sum` are deliberately rejected until their oracle fixtures and issue contracts land, so this registration is not a full legacy parity claim. `axioval:capability.free-floor-circle` and `axioval:capability.free-floor-rectangle` check whether each selected spatial scope can contain an exact supported vertical shape. Circle parameters are `diameter_metres` and `height_metres`; rectangle parameters are `width_metres`, `length_metres`, and `height_metres`, all in canonical metres. Each request covers every other project object as a candidate obstacle and requires exact whole-base support on the selected scope with zero hidden gap. A complete exact no-placement proof emits the shape-specific `NO_FREE_FLOOR_SPACE_*` finding; missing services, backend outages, or invalid/incomplete evidence emit not-evaluated outcomes instead. diff --git a/docs/src/ir.md b/docs/src/ir.md index eb8784c5..d48b604e 100644 --- a/docs/src/ir.md +++ b/docs/src/ir.md @@ -20,7 +20,7 @@ An alias is an `ExternalId`: an adapter-defined `scheme` and a `value`, listed i ## Semantic data -Objects expose canonical concepts, typed properties, classifications and directed relationships. Canonical concept IDs are package vocabulary identifiers; source-specific names are adapter bindings. Stored property values are observations, not proof that an omitted key is absent. Conclusive property checks use the typed property-resolution service and exact request-bound evidence. +Objects expose canonical concepts, typed properties, classifications and directed relationships. Canonical concept IDs are package vocabulary identifiers; source-specific names are adapter bindings. Stored property values are observations, not proof that an omitted key is absent. A property may carry `data_type`, the value's type as the source declares it in its own vocabulary (IFC: `IFCLABEL`); `None` means unreported, never any particular type. Conclusive property checks use the typed property-resolution service and exact request-bound evidence. Values distinguish null/unavailable from concrete values and preserve units where relevant. Adapters must not silently coerce malformed source values. diff --git a/staging/AGENTS.md b/staging/AGENTS.md index 9627c708..37760d3f 100644 --- a/staging/AGENTS.md +++ b/staging/AGENTS.md @@ -1,8 +1,9 @@ # `staging/` -Work that depends on **unpublished** crates, kept deliberately outside the -shipping workspace and outside every release. `axioval-bcf` graduated from -here to `crates/sinks/bcf/` once the `openbim-bcf` writer was published. +Work that depends on **unpublished** crates (Axiolid kernel crates, the +`openbim-ids` reader), kept deliberately outside the shipping workspace and +outside every release. `axioval-bcf` graduated from here to +`crates/sinks/bcf/` once the `openbim-bcf` writer was published. ## Why this exists @@ -60,3 +61,20 @@ All four scenarios were verified to fail the gate before it was trusted. - `axiolid-routing/` — `MetricRoutingService` / `WalkabilityService` groundwork over `axiolid-route`. Proven working locally: 8.0000 m direct vs 8.2462 m around a barrier, with a visibility graph of 6 and 8 vertices. +- `ids/` — `axioval-ids`, the IDS package importer: translates a + buildingSMART IDS 1.0 document into a definition package and a ruleset, and + reports every facet it cannot translate exactly as a `Gap` instead of + dropping it. An untranslatable applicability facet leaves its whole + specification without rules, because dropping it would widen the checked + population. Depends on `axioval-ir` and the `openbim-ids` reader, which is + unreleased (openbimrs/ids#5) and is taken from its `feat/ids-reader` branch; + to build against a local checkout, add a `[patch]` for it in an untracked + `staging/ids/.cargo/config.toml`. The conformance harness needs the + buildingSMART corpus, which is CC BY-ND 4.0 and not vendored: + `IDS_TEST_CASES=/Documentation/ImplementersDocumentation/TestCases cargo test -- --ignored corpus`. + It asserts that no translated rule fails a `pass-` case and that every + unflagged `fail-` case is explained by a reported gap. + `cargo run --example coverage -- *.ids` ranks the gaps of real documents. + When the reader is published, switch to a version requirement, move the + crate to `crates/packages/ids/` (it produces packages and adapts no + source), and add it to the gate lists. diff --git a/staging/ids/Cargo.toml b/staging/ids/Cargo.toml new file mode 100644 index 00000000..88ba9d36 --- /dev/null +++ b/staging/ids/Cargo.toml @@ -0,0 +1,38 @@ +# Standalone: NOT a member of the engine workspace. It cannot be published +# while the `openbim-ids` reader is unreleased (openbimrs/ids#5). See +# staging/AGENTS.md for the rewiring steps. +[workspace] + +[package] +name = "axioval-ids" +version = "0.0.0" +edition = "2024" +rust-version = "1.88" +license = "AGPL-3.0-or-later" +description = "Staging: Axioval rule packages from buildingSMART IDS documents" +publish = false + +[dependencies] +# The package contracts this importer writes. +axioval-ir = { path = "../../crates/contracts/ir" } +# UNPUBLISHED reader: a git dependency until a release carrying +# `openbim_ids::read` lands on crates.io. To build against a local checkout, +# add to staging/ids/.cargo/config.toml (untracked): +# [patch."https://github.com/openbimrs/ids"] +# openbim-ids = { path = "/path/to/ids/openbim-ids" } +openbim-ids = { git = "https://github.com/openbimrs/ids", branch = "feat/ids-reader" } +# Which IDS classes are IfcObject occurrences, per release. +ifc-schema = "0.2.2" +thiserror = "2.0.16" + +[dev-dependencies] +axioval = { path = "../../crates/facade/axioval", features = ["ifc"] } +serde_json = "1.0.143" + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } diff --git a/staging/ids/examples/coverage.rs b/staging/ids/examples/coverage.rs new file mode 100644 index 00000000..55680810 --- /dev/null +++ b/staging/ids/examples/coverage.rs @@ -0,0 +1,57 @@ +//! Reports how much of each IDS document translates, and why the rest does not. +//! +//! ```text +//! cargo run --example coverage -- a.ids b.ids ... +//! ``` +#![allow(missing_docs)] + +use std::collections::BTreeMap; + +use axioval_ids::{Options, translate}; + +fn main() { + let options = Options { + package_id: "ids:coverage".into(), + version: "0.0.0".into(), + }; + let mut reasons: BTreeMap = BTreeMap::new(); + let (mut specifications, mut complete, mut skipped, mut rules) = (0, 0, 0, 0); + for path in std::env::args().skip(1) { + let ids = match std::fs::read(&path) + .map_err(|error| error.to_string()) + .and_then(|bytes| openbim_ids::from_slice(&bytes).map_err(|error| error.to_string())) + { + Ok(ids) => ids, + Err(error) => { + println!("{path}: unreadable: {error}"); + continue; + } + }; + let translation = translate(&ids, &options).expect("valid options"); + for outcome in &translation.specifications { + specifications += 1; + rules += outcome.rules.len(); + complete += usize::from(outcome.is_complete()); + skipped += usize::from(outcome.is_skipped()); + } + for (_, gap) in translation.gaps() { + // Group by reason kind, not by the names inside it. + let reason = gap.reason.to_string(); + let key = reason + .split('"') + .next() + .unwrap_or(&reason) + .trim() + .to_owned(); + *reasons.entry(key).or_default() += 1; + } + } + println!( + "{specifications} specifications: {complete} complete, {skipped} skipped, {rules} rules" + ); + let mut ranked: Vec<_> = reasons.into_iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + for (reason, count) in ranked { + println!("{count:5} {reason}"); + } +} diff --git a/staging/ids/src/lib.rs b/staging/ids/src/lib.rs new file mode 100644 index 00000000..72412323 --- /dev/null +++ b/staging/ids/src/lib.rs @@ -0,0 +1,1024 @@ +#![allow(clippy::doc_markdown)] + +//! Axioval rule packages from buildingSMART IDS documents. +//! +//! A package importer, not a source adapter: it reads an IDS document through +//! [`openbim_ids`] and writes a [`DefinitionPackage`] and a [`RuleSetPackage`] +//! that select the engine's trusted capabilities. It never looks at a model. +//! +//! # Exact or not at all +//! +//! An IDS facet becomes a rule only when an existing capability decides it +//! exactly as IDS does. Everything else is reported as a [`Gap`], with the +//! part of the specification it concerns and why, so a caller can refuse an +//! incomplete translation instead of mistaking silence for a pass. +//! +//! The two sides of a specification fail differently: +//! +//! - **Applicability** decides which objects are checked. Dropping a facet +//! would widen the population and report objects IDS never applied to, so +//! one untranslatable applicability facet leaves the whole specification +//! without rules. +//! - **Requirements** are independent of each other. Dropping one can only +//! miss failures, never invent them, so the others are still translated. +//! +//! # Releases +//! +//! A specification's concepts are named only in the type systems of the IFC +//! releases it lists, so a rule for an IFC4-only specification cannot bind to +//! an IFC2X3 model: the engine reports it as not evaluated +//! (`InvalidDeclaration`) rather than applying it. `IFC4X3_ADD2` has no type +//! system any Axioval adapter declares and is reported as a gap. + +use std::collections::BTreeMap; +use std::fmt; + +use axioval_ir::contract::{ + DefinitionPackage, ExternalName, LocalizedText, ObjectTypeDefinition, PackageMetadata, + ParameterDefinition, ParameterKind, ParameterValue, PropertyDefinition, PropertySetDefinition, + PropertyValueKind, RuleApplicability, RuleDefinition, RuleFolder, RuleInstance, RuleSetPackage, + Selector, Severity, +}; +use openbim_ids::{ + Entity, Facet, Ids, IfcVersion, Occurrence, Property, Requirement, Specification, Value, +}; +use thiserror::Error; + +/// Type system of IFC2X3 TC1, as the IFC adapter declares it. +/// +/// A test pins this to the adapter's constant so this crate need not depend +/// on the adapter. +pub const IFC2X3_TYPE_SYSTEM: &str = + "https://standards.buildingsmart.org/IFC/RELEASE/IFC2x3/TC1/HTML/"; + +/// Type system of IFC4 ADD2 TC1, as the IFC adapter declares it. +pub const IFC4_TYPE_SYSTEM: &str = "https://identifier.buildingsmart.org/uri/buildingsmart/ifc/4"; + +/// The package schema version the engine compiles. +const SCHEMA_VERSION: &str = "0.1.0"; + +const PROPERTY_REQUIRED: &str = "axioval:capability.property-required"; +const PROPERTY_DATA_TYPE: &str = "axioval:capability.property-data-type"; +const PROPERTY_VALUE: &str = "axioval:capability.property-value"; + +/// Identity of the packages written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Options { + /// Qualified id of the ruleset package, such as `ids:fire-safety`. The + /// definition package and every concept are named under it. + pub package_id: String, + /// Semantic version of both packages. + pub version: String, +} + +/// Why the options were refused. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum OptionsError { + /// The package id is not `scheme:name` with a lower-case scheme. + #[error("package id {0:?} is not a qualified id such as `ids:fire-safety`")] + PackageId(String), + /// The version is not `major.minor.patch`. + #[error("version {0:?} is not a semantic version such as `1.0.0`")] + Version(String), +} + +/// An IDS document translated into Axioval packages. +#[derive(Clone, Debug, PartialEq)] +pub struct Translation { + /// Object-type, property and property-set concepts, and the rule + /// definitions the ruleset uses. + pub definitions: DefinitionPackage, + /// One folder per specification that produced rules. + pub ruleset: RuleSetPackage, + /// What became of each specification, in document order. + pub specifications: Vec, +} + +impl Translation { + /// Whether every specification translated without a gap. + #[must_use] + pub fn is_complete(&self) -> bool { + self.specifications + .iter() + .all(SpecificationOutcome::is_complete) + } + + /// Every gap, with the specification it belongs to. + pub fn gaps(&self) -> impl Iterator { + self.specifications + .iter() + .flat_map(|outcome| outcome.gaps.iter().map(move |gap| (outcome, gap))) + } +} + +/// What became of one specification. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpecificationOutcome { + /// Position in the document, from 1. + pub number: usize, + /// `@name`. + pub name: String, + /// Ids of the rules written for it. + pub rules: Vec, + /// What was not translated. + pub gaps: Vec, +} + +impl SpecificationOutcome { + /// Whether the rules decide the specification exactly as IDS does. + #[must_use] + pub fn is_complete(&self) -> bool { + self.gaps.is_empty() + } + + /// Whether nothing of the specification is checked: its applicability + /// is untranslatable, or none of its releases is supported. + #[must_use] + pub fn is_skipped(&self) -> bool { + self.gaps.iter().any(Gap::skips) + } +} + +/// Something in a specification that has no exact translation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Gap { + /// Where in the specification. + pub part: Part, + /// Why. + pub reason: Reason, +} + +impl Gap { + /// Whether this gap leaves the specification without rules: an + /// untranslatable applicability, or no release to bind to. An + /// unsupported release beside a supported one only narrows the models + /// the rules run on. + fn skips(&self) -> bool { + matches!(self.part, Part::Applicability { .. }) || self.reason == Reason::NoSupportedRelease + } +} + +impl fmt::Display for Gap { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.part, self.reason) + } +} + +/// The part of a specification a [`Gap`] concerns. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Part { + /// `@ifcVersion`. + Releases, + /// The applicability's `minOccurs`/`maxOccurs`. + Occurrence, + /// An applicability facet, numbered from 1 in document order. + Applicability { + /// Position among the applicability facets. + facet: usize, + }, + /// A requirement facet, numbered from 1 in document order. + Requirement { + /// Position among the requirement facets. + facet: usize, + }, +} + +impl fmt::Display for Part { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Part::Releases => f.write_str("ifcVersion"), + Part::Occurrence => f.write_str("applicability occurrence"), + Part::Applicability { facet } => write!(f, "applicability facet {facet}"), + Part::Requirement { facet } => write!(f, "requirement facet {facet}"), + } + } +} + +/// Why a part has no exact translation. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Reason { + /// `IFC4X3_ADD2`, for which no adapter declares a type system. + UnsupportedRelease(IfcVersion), + /// No listed release is supported, so no rule could bind to any model. + NoSupportedRelease, + /// At least one applicable object must exist. A report carries findings + /// about objects only, so "none exists" has nowhere to go. + Existence, + /// No applicable object may exist. + Prohibition, + /// A bounded count of applicable objects other than "at least one". + Count { + /// `minOccurs`. + min: u32, + /// `maxOccurs`; `None` is unbounded. + max: Option, + }, + /// An applicability without facets. + EmptyApplicability, + /// A facet kind no capability decides yet, named as IDS spells it. + FacetKind(&'static str), + /// An applicability class the release does not define. + UnknownEntity { + /// The class as written. + entity: String, + /// The release that lacks it. + release: IfcVersion, + }, + /// An applicability class whose instances are not `IfcObject` + /// occurrences (a type object, an IFC4 `IfcProject`, a resource), which + /// an IFC session does not make project objects of. + NotAnObject(String), + /// An entity facet naming a predefined type. + PredefinedType, + /// An entity name that is not upper case, which IDS never matches. + EntityCase(String), + /// A class, property set or property name given as an `xs:restriction`; + /// only an enumeration of classes translates. + Restriction, + /// A restriction facet no capability applies, named as XML Schema + /// spells it (`totalDigits`, `fractionDigits`). + RestrictionFacet(&'static str), + /// A restriction with no facets, whose meaning IDS leaves open. + EmptyRestriction, + /// A prohibited facet. + Prohibited, + /// An entity requirement naming a class other than the applicability's. + EntityRequirement, +} + +impl fmt::Display for Reason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Reason::UnsupportedRelease(release) => { + write!(f, "{release} has no type system an adapter declares") + } + Reason::NoSupportedRelease => f.write_str("no listed IFC release is supported"), + Reason::Existence => f.write_str( + "requires at least one applicable object; reports cannot state that none exists", + ), + Reason::Prohibition => f.write_str("requires that no applicable object exists"), + Reason::Count { min, max } => match max { + Some(max) => write!(f, "requires {min} to {max} applicable objects"), + None => write!(f, "requires at least {min} applicable objects"), + }, + Reason::EmptyApplicability => f.write_str("the applicability has no facets"), + Reason::FacetKind(kind) => write!(f, "no capability decides a {kind} facet"), + Reason::UnknownEntity { entity, release } => { + write!(f, "{release} defines no entity {entity}") + } + Reason::NotAnObject(entity) => write!( + f, + "{entity} is not an IfcObject occurrence, which is all a model session checks" + ), + Reason::PredefinedType => f.write_str("no capability decides a predefined type"), + Reason::EntityCase(name) => { + write!( + f, + "entity name {name:?} is not upper case, which IDS never matches" + ) + } + Reason::Restriction => f.write_str( + "a class, property set or property name given as a restriction is not translated", + ), + Reason::RestrictionFacet(facet) => { + write!(f, "no capability applies the restriction facet {facet}") + } + Reason::EmptyRestriction => f.write_str("a restriction without facets"), + Reason::Prohibited => f.write_str("no capability decides a prohibited facet"), + Reason::EntityRequirement => f.write_str( + "an entity requirement other than the applicability's own entity is not decided", + ), + } + } +} + +/// Translates `ids` into packages identified by `options`. +/// +/// # Errors +/// +/// Returns an [`OptionsError`] when the package id or version is malformed. +/// A document that cannot be translated fully is not an error; see +/// [`Translation::specifications`]. +pub fn translate(ids: &Ids, options: &Options) -> Result { + if !is_qualified_id(&options.package_id) { + return Err(OptionsError::PackageId(options.package_id.clone())); + } + if !is_semver(&options.version) { + return Err(OptionsError::Version(options.version.clone())); + } + let mut writer = Writer::new(options); + let mut folders = Vec::new(); + let mut specifications = Vec::new(); + for (index, specification) in ids.specifications.iter().enumerate() { + let number = index + 1; + let (rules, gaps) = writer.specification(number, specification); + let outcome = SpecificationOutcome { + number, + name: specification.name.clone(), + rules: rules.iter().map(|rule| rule.id.clone()).collect(), + gaps, + }; + if !rules.is_empty() { + folders.push(RuleFolder { + id: format!("spec{number}"), + name: LocalizedText::plain(&specification.name), + description: specification + .description + .as_deref() + .map(LocalizedText::plain), + rules, + folders: Vec::new(), + }); + } + specifications.push(outcome); + } + let title = &ids.info.title; + let definitions_id = format!("{}.definitions", options.package_id); + let metadata = |id: &str, name: String| PackageMetadata { + id: id.to_owned(), + name: LocalizedText::plain(name), + version: options.version.clone(), + description: ids.info.description.as_deref().map(LocalizedText::plain), + repository: None, + license: None, + authors: ids.info.author.iter().cloned().collect(), + }; + Ok(Translation { + definitions: DefinitionPackage { + schema_version: SCHEMA_VERSION.to_owned(), + package: metadata(&definitions_id, format!("{title}: definitions")), + sources: BTreeMap::new(), + object_types: writer.object_types, + properties: writer.properties, + property_sets: writer.property_sets, + definitions: writer.definitions, + }, + ruleset: RuleSetPackage { + schema_version: SCHEMA_VERSION.to_owned(), + package: metadata(&options.package_id, title.clone()), + sources: BTreeMap::new(), + definition_packages: vec![definitions_id], + root: RuleFolder { + id: "ids".to_owned(), + name: LocalizedText::plain(title), + description: None, + rules: Vec::new(), + folders, + }, + }, + specifications, + }) +} + +/// Accumulates concepts and definitions across specifications. +struct Writer<'o> { + options: &'o Options, + /// Concept ids by (kind, releases, name), so equal concepts are shared. + concepts: BTreeMap<(&'static str, Vec, String), String>, + object_types: BTreeMap, + properties: BTreeMap, + property_sets: BTreeMap, + definitions: BTreeMap, +} + +impl<'o> Writer<'o> { + fn new(options: &'o Options) -> Self { + Self { + options, + concepts: BTreeMap::new(), + object_types: BTreeMap::new(), + properties: BTreeMap::new(), + property_sets: BTreeMap::new(), + definitions: BTreeMap::new(), + } + } + + fn specification( + &mut self, + number: usize, + specification: &Specification, + ) -> (Vec, Vec) { + let mut gaps = Vec::new(); + let releases = releases(specification, &mut gaps); + occurrence(specification, &mut gaps); + let applicability = applicability(specification, &releases, &mut gaps); + let requirements = specification + .requirements + .iter() + .flat_map(|requirements| requirements.facets.iter()) + .enumerate() + .filter_map(|(index, requirement)| { + let part = Part::Requirement { facet: index + 1 }; + match check(requirement, applicability.as_ref().map(|(_, e)| *e)) { + Ok(check) => check.map(|check| (index + 1, requirement, check)), + Err(reason) => { + gaps.push(Gap { part, reason }); + None + } + } + }) + .collect::>(); + let skipped = gaps.iter().any(Gap::skips); + let Some((entities, _)) = applicability.filter(|_| !skipped) else { + return (Vec::new(), gaps); + }; + let selector = self.selector(&entities, &releases); + let rules = requirements + .into_iter() + .map(|(facet, requirement, check)| { + self.rule( + number, + facet, + specification, + requirement, + check, + &selector, + &releases, + ) + }) + .collect(); + (rules, gaps) + } + + fn selector(&mut self, entities: &[String], releases: &[IfcVersion]) -> Selector { + let mut operands: Vec = entities + .iter() + .map(|entity| Selector::EntityType { + object_type: self.object_type(entity, releases), + // IDS matches the named class only, never its subclasses. + include_subtypes: false, + }) + .collect(); + if operands.len() == 1 { + operands.remove(0) + } else { + Selector::AnyOf { operands } + } + } + + #[allow(clippy::too_many_arguments)] + fn rule( + &mut self, + number: usize, + facet: usize, + specification: &Specification, + requirement: &Requirement, + check: Check, + selector: &Selector, + releases: &[IfcVersion], + ) -> RuleInstance { + let Check { + set, + name, + kind, + parameters: extra, + } = check; + let definition_id = self.definition(kind); + let property = self.property(&set, &name, releases); + let property_set = self.property_set(&set, releases); + let mut parameters = BTreeMap::from([( + "property".to_owned(), + ParameterValue::PropertyReference { + property, + property_set: Some(property_set), + }, + )]); + parameters.extend(extra); + let title = match kind { + CheckKind::Required => format!("{set}.{name} is required"), + CheckKind::DataType => format!("{set}.{name} is required with a declared type"), + CheckKind::Value if parameters.contains_key("optional") => { + format!("{set}.{name}, where present, meets its value constraints") + } + CheckKind::Value => format!("{set}.{name} meets its value constraints"), + }; + let description = requirement + .instructions + .as_deref() + .or(specification.instructions.as_deref()) + .map(LocalizedText::plain); + RuleInstance { + id: format!("spec{number}.facet{facet}"), + definition_id, + name: LocalizedText::plain(title), + description, + enabled: true, + severity: Severity::Error, + message: None, + parameters, + applicability: RuleApplicability::Selector(selector.clone()), + requirements: Vec::new(), + citations: Vec::new(), + parameter_citations: Vec::new(), + explanatory_images: Vec::new(), + tags: vec!["ids".to_owned()], + } + } + + /// The rule definition a kind of check uses, written once. + fn definition(&mut self, kind: CheckKind) -> String { + let (suffix, name, description, capability) = match kind { + CheckKind::Required => ( + "property-required", + "Property is required", + "An IDS property facet without a value: the property must exist with a non-empty value.", + PROPERTY_REQUIRED, + ), + CheckKind::DataType => ( + "property-data-type", + "Property is required with a data type", + "An IDS property facet with a dataType and no value: the property must exist with a non-empty value of that declared type.", + PROPERTY_DATA_TYPE, + ), + CheckKind::Value => ( + "property-value", + "Property value meets constraints", + "An IDS property facet with a value, or an optional one with a dataType: literals and XML Schema facets cast to the property's value.", + PROPERTY_VALUE, + ), + }; + let id = format!("{}.{suffix}", self.options.package_id); + self.definitions.entry(id.clone()).or_insert_with(|| { + let mut parameters = BTreeMap::from([( + "property".to_owned(), + parameter("property", ParameterKind::PropertyReference, true), + )]); + let optional: &[(&str, ParameterKind)] = match kind { + CheckKind::Required => &[], + CheckKind::DataType => { + parameters.insert( + "data_type".to_owned(), + parameter("data_type", ParameterKind::String, true), + ); + &[] + } + CheckKind::Value => &[ + ("data_type", ParameterKind::String), + ("values", ParameterKind::StringList), + ("patterns", ParameterKind::StringList), + ("min_inclusive", ParameterKind::String), + ("max_inclusive", ParameterKind::String), + ("min_exclusive", ParameterKind::String), + ("max_exclusive", ParameterKind::String), + ("length", ParameterKind::Integer), + ("min_length", ParameterKind::Integer), + ("max_length", ParameterKind::Integer), + ("optional", ParameterKind::Boolean), + ], + }; + for (id, kind) in optional { + parameters.insert((*id).to_owned(), parameter(id, kind.clone(), false)); + } + RuleDefinition { + id: id.clone(), + name: LocalizedText::plain(name), + description: Some(LocalizedText::plain(description)), + capability: capability.to_owned(), + parameters, + tags: vec!["ids".to_owned()], + citations: Vec::new(), + } + }); + id + } + + /// The id of the concept `(kind, releases, name)`, allocating it once. + fn concept( + &mut self, + kind: &'static str, + releases: &[IfcVersion], + name: &str, + ) -> (String, bool) { + let key = (kind, releases.to_vec(), name.to_owned()); + if let Some(id) = self.concepts.get(&key) { + return (id.clone(), false); + } + let count = self.concepts.keys().filter(|(k, ..)| *k == kind).count(); + let id = format!("{}.{kind}-{}", self.options.package_id, count + 1); + self.concepts.insert(key, id.clone()); + (id, true) + } + + fn object_type(&mut self, entity: &str, releases: &[IfcVersion]) -> String { + let (id, new) = self.concept("entity", releases, entity); + if new { + self.object_types.insert( + id.clone(), + ObjectTypeDefinition { + id: id.clone(), + name: LocalizedText::plain(entity), + description: None, + external_names: names(releases, entity), + citations: Vec::new(), + }, + ); + } + id + } + + fn property(&mut self, set: &str, name: &str, releases: &[IfcVersion]) -> String { + // A property is identified by its set too: `Width` in two sets is two + // properties, even though only the name is bound. + let (id, new) = self.concept("property", releases, &format!("{set}\u{0}{name}")); + if new { + self.properties.insert( + id.clone(), + PropertyDefinition { + id: id.clone(), + name: LocalizedText::plain(name), + description: Some(LocalizedText::plain(format!( + "{set}.{name}. IDS states no value kind for a presence check; `string` is nominal." + ))), + value_kind: PropertyValueKind::String, + unit_dimension: None, + external_names: names(releases, name), + citations: Vec::new(), + }, + ); + } + id + } + + fn property_set(&mut self, set: &str, releases: &[IfcVersion]) -> String { + let (id, new) = self.concept("property-set", releases, set); + if new { + self.property_sets.insert( + id.clone(), + PropertySetDefinition { + id: id.clone(), + name: LocalizedText::plain(set), + description: None, + external_names: names(releases, set), + citations: Vec::new(), + }, + ); + } + id + } +} + +/// A parameter without default or allowed values. +fn parameter(id: &str, kind: ParameterKind, required: bool) -> ParameterDefinition { + ParameterDefinition { + id: id.to_owned(), + name: LocalizedText::plain(id), + description: None, + kind, + referenced_value_kind: None, + required, + default_value: None, + allowed_values: Vec::new(), + unit_dimension: None, + citations: Vec::new(), + } +} + +/// One exactly translatable requirement. +struct Check { + set: String, + name: String, + kind: CheckKind, + /// Parameters besides the property reference. + parameters: BTreeMap, +} + +/// Which capability decides a check. +#[derive(Clone, Copy)] +enum CheckKind { + /// `property-required`. + Required, + /// `property-data-type`. + DataType, + /// `property-value`. + Value, +} + +/// The supported releases, recording a gap for each unsupported one. +fn releases(specification: &Specification, gaps: &mut Vec) -> Vec { + let mut supported = Vec::new(); + for release in &specification.ifc_versions { + if type_system(*release).is_some() { + if !supported.contains(release) { + supported.push(*release); + } + } else { + gaps.push(Gap { + part: Part::Releases, + reason: Reason::UnsupportedRelease(*release), + }); + } + } + supported.sort_unstable(); + if supported.is_empty() { + gaps.push(Gap { + part: Part::Releases, + reason: Reason::NoSupportedRelease, + }); + } + supported +} + +fn type_system(release: IfcVersion) -> Option<&'static str> { + match release { + IfcVersion::Ifc2x3 => Some(IFC2X3_TYPE_SYSTEM), + IfcVersion::Ifc4 => Some(IFC4_TYPE_SYSTEM), + IfcVersion::Ifc4x3Add2 => None, + } +} + +fn names(releases: &[IfcVersion], name: &str) -> Vec { + releases + .iter() + .filter_map(|release| type_system(*release)) + .map(|type_system| ExternalName { + type_system: type_system.to_owned(), + name: name.to_owned(), + }) + .collect() +} + +/// Records a gap unless the bounds ask only that every applicable object +/// meet the requirements. +fn occurrence(specification: &Specification, gaps: &mut Vec) { + let applicability = &specification.applicability; + let reason = match (applicability.min_occurs, applicability.max_occurs) { + (0, None) => return, + (_, Some(0)) => Reason::Prohibition, + (1, None) => Reason::Existence, + (min, max) => Reason::Count { min, max }, + }; + gaps.push(Gap { + part: Part::Occurrence, + reason, + }); +} + +/// The entity names the applicability selects, and the entity facet itself. +/// +/// `None` when any facet is untranslatable; the gaps say which. +fn applicability<'s>( + specification: &'s Specification, + releases: &[IfcVersion], + gaps: &mut Vec, +) -> Option<(Vec, &'s Entity)> { + let facets = &specification.applicability.facets; + if facets.is_empty() { + gaps.push(Gap { + part: Part::Applicability { facet: 1 }, + reason: Reason::EmptyApplicability, + }); + return None; + } + let mut selected = None; + for (index, facet) in facets.iter().enumerate() { + let part = Part::Applicability { facet: index + 1 }; + let result = match facet { + Facet::Entity(entity) => entity_names(entity) + .and_then(|names| occurrences(names, releases)) + .map(|names| selected = Some((names, entity))), + other => Err(Reason::FacetKind(other.kind())), + }; + if let Err(reason) = result { + gaps.push(Gap { part, reason }); + } + } + let translated = !gaps + .iter() + .any(|gap| matches!(gap.part, Part::Applicability { .. })); + selected.filter(|_| translated) +} + +/// Refuses a class whose instances are not checked objects. +/// +/// An IFC session makes a project object of every `IfcObject` occurrence and +/// of nothing else. A rule over a type object, an IFC4 `IfcProject` (an +/// `IfcContext`) or a resource would select nothing and pass silently, so +/// such a class, and one the release does not define, is a gap. +fn occurrences(names: Vec, releases: &[IfcVersion]) -> Result, Reason> { + for release in releases { + let schema = match release { + IfcVersion::Ifc2x3 => ifc_schema::ifc2x3(), + IfcVersion::Ifc4 => ifc_schema::ifc4(), + // No type system either; reported as a release gap. + IfcVersion::Ifc4x3Add2 => continue, + }; + for name in &names { + if schema.entity(name).is_none() { + return Err(Reason::UnknownEntity { + entity: name.clone(), + release: *release, + }); + } + if !schema.is_a(name, "IFCOBJECT") { + return Err(Reason::NotAnObject(name.clone())); + } + } + } + Ok(names) +} + +/// The class names an entity facet matches exactly. +fn entity_names(entity: &Entity) -> Result, Reason> { + if entity.predefined_type.is_some() { + return Err(Reason::PredefinedType); + } + let names = match &entity.name { + Value::Simple(name) => vec![name.clone()], + // An enumeration of literals is a set of classes; nothing else in a + // restriction is a literal. + Value::Restriction(restriction) + if restriction.base == "string" + && !restriction.enumeration.is_empty() + && restriction.patterns.is_empty() + && restriction.min_inclusive.is_none() + && restriction.max_inclusive.is_none() + && restriction.min_exclusive.is_none() + && restriction.max_exclusive.is_none() + && restriction.length.is_none() + && restriction.min_length.is_none() + && restriction.max_length.is_none() + && restriction.total_digits.is_none() + && restriction.fraction_digits.is_none() => + { + restriction.enumeration.clone() + } + Value::Restriction(_) => return Err(Reason::Restriction), + }; + // The engine compares kinds case-insensitively; IDS does not. + if let Some(name) = names + .iter() + .find(|name| name.chars().any(char::is_lowercase)) + { + return Err(Reason::EntityCase(name.clone())); + } + Ok(names) +} + +/// The exact check for a requirement, `None` when it always holds. +fn check( + requirement: &Requirement, + applicability: Option<&Entity>, +) -> Result, Reason> { + match &requirement.facet { + Facet::Property(property) => property_check(property, requirement.occurrence), + Facet::Entity(entity) => { + // Requiring the class the applicability already selected always + // holds. Anything else needs an entity capability. + let same = applicability.is_some_and(|applicable| { + applicable.predefined_type.is_none() + && entity.predefined_type.is_none() + && matches!((&applicable.name, &entity.name), (Value::Simple(a), Value::Simple(b)) if a == b) + }); + if same { + Ok(None) + } else { + Err(Reason::EntityRequirement) + } + } + other => Err(Reason::FacetKind(other.kind())), + } +} + +fn property_check(property: &Property, occurrence: Occurrence) -> Result, Reason> { + let (Value::Simple(set), Value::Simple(name)) = (&property.property_set, &property.base_name) + else { + return Err(Reason::Restriction); + }; + let optional = match occurrence { + Occurrence::Required => false, + Occurrence::Optional => true, + Occurrence::Prohibited => return Err(Reason::Prohibited), + }; + let mut parameters = BTreeMap::new(); + if let Some(data_type) = &property.data_type { + parameters.insert( + "data_type".to_owned(), + ParameterValue::String { + value: data_type.clone(), + }, + ); + } + let kind = match (&property.value, optional, &property.data_type) { + (None, false, None) => CheckKind::Required, + (None, false, Some(_)) => CheckKind::DataType, + // Without a value or type, an optional property is satisfied + // whether or not it is there. + (None, true, None) => return Ok(None), + (None, true, Some(_)) => CheckKind::Value, + (Some(value), ..) => { + value_parameters(value, &mut parameters)?; + CheckKind::Value + } + }; + if optional { + parameters.insert( + "optional".to_owned(), + ParameterValue::Boolean { value: true }, + ); + } + Ok(Some(Check { + set: set.clone(), + name: name.clone(), + kind, + parameters, + })) +} + +/// The `property-value` parameters an IDS value stands for. +fn value_parameters( + value: &Value, + parameters: &mut BTreeMap, +) -> Result<(), Reason> { + let restriction = match value { + Value::Simple(literal) => { + parameters.insert( + "values".to_owned(), + ParameterValue::StringList { + value: vec![literal.clone()], + }, + ); + return Ok(()); + } + Value::Restriction(restriction) => restriction, + }; + if restriction.total_digits.is_some() { + return Err(Reason::RestrictionFacet("totalDigits")); + } + if restriction.fraction_digits.is_some() { + return Err(Reason::RestrictionFacet("fractionDigits")); + } + let before = parameters.len(); + let mut add = |name: &str, value: ParameterValue| { + parameters.insert(name.to_owned(), value); + }; + for (name, list) in [ + ("values", &restriction.enumeration), + ("patterns", &restriction.patterns), + ] { + if !list.is_empty() { + add( + name, + ParameterValue::StringList { + value: list.clone(), + }, + ); + } + } + for (name, bound) in [ + ("min_inclusive", &restriction.min_inclusive), + ("max_inclusive", &restriction.max_inclusive), + ("min_exclusive", &restriction.min_exclusive), + ("max_exclusive", &restriction.max_exclusive), + ] { + if let Some(bound) = bound { + add( + name, + ParameterValue::String { + value: bound.clone(), + }, + ); + } + } + for (name, count) in [ + ("length", restriction.length), + ("min_length", restriction.min_length), + ("max_length", restriction.max_length), + ] { + if let Some(count) = count { + // A length beyond `i64` cannot be met by any value; `MAX` keeps + // that meaning for `max_length` and fails every `length`. + let value = i64::try_from(count).unwrap_or(i64::MAX); + add(name, ParameterValue::Integer { value }); + } + } + if parameters.len() == before { + return Err(Reason::EmptyRestriction); + } + Ok(()) +} + +/// `scheme:rest` with a lower-case scheme, as MCS qualified ids are. +fn is_qualified_id(id: &str) -> bool { + let Some((scheme, rest)) = id.split_once(':') else { + return false; + }; + let mut chars = scheme.chars(); + chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "+.-".contains(c)) + && !rest.is_empty() + && !id.chars().any(char::is_whitespace) +} + +/// `major.minor.patch` without leading zeros; pre-release and build suffixes +/// are accepted as MCS accepts them. +fn is_semver(version: &str) -> bool { + let core = version.split(['-', '+']).next().unwrap_or(""); + let parts: Vec<&str> = core.split('.').collect(); + parts.len() == 3 + && parts.iter().all(|part| { + !part.is_empty() + && part.bytes().all(|b| b.is_ascii_digit()) + && (part.len() == 1 || !part.starts_with('0')) + }) +} diff --git a/staging/ids/tests/corpus.rs b/staging/ids/tests/corpus.rs new file mode 100644 index 00000000..dcabd72c --- /dev/null +++ b/staging/ids/tests/corpus.rs @@ -0,0 +1,201 @@ +//! The translation against the buildingSMART IDS test corpus. +//! +//! Each case pairs an `.ids` with an `.ifc` and states in its name whether the +//! model passes. The translation is run through the real IFC adapter and +//! engine, and every case must land in a class that is consistent with it: +//! +//! - a `pass-` case produces no finding: translated rules never invent a +//! failure, complete or not; +//! - a `fail-` case either produces a finding, or its translation reported a +//! gap that explains why not. A complete translation with no finding is a +//! miss. So is one whose only gap is existence while the model does contain +//! an applicable object. +//! +//! `invalid-` cases judge the IDS against the IFC schema and are skipped. +//! The corpus is CC BY-ND 4.0 and is not vendored: point `IDS_TEST_CASES` at +//! `Documentation/ImplementersDocumentation/TestCases` of +//! and run +//! `cargo test -- --ignored corpus`. +#![allow(missing_docs, clippy::doc_markdown)] + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use axioval::default_registry; +use axioval::engine::{Runtime, compile}; +use axioval::ifc::import_ifc_session; +use axioval_ids::{Options, Part, Reason, Translation, translate}; + +fn cases() -> Vec { + let root = PathBuf::from( + std::env::var_os("IDS_TEST_CASES") + .expect("set IDS_TEST_CASES to the buildingSMART IDS TestCases directory"), + ); + let mut found = Vec::new(); + let mut stack = vec![root]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("readable corpus directory") { + let path = entry.expect("readable entry").path(); + if path.is_dir() { + stack.push(path); + } else if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("ids")) + { + found.push(path); + } + } + } + found.sort(); + found +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Class { + /// A pass case whose complete translation found nothing. + ExactPass, + /// A pass case whose partial translation found nothing. + SoundPass, + /// A fail case the rules caught. + CaughtFail, + /// A fail case with no finding, explained by existence: the model has + /// no applicable object and reports cannot say so. + ExistenceFail, + /// A fail case with no finding and gaps that may explain it. + UnjudgedFail, + /// The adapter refused the model, e.g. an IFC4X3 file. + ModelRefused, + /// Rules could not be evaluated; not a verdict either way. + NotEvaluated, + /// A false failure or an unexplained miss. + Mismatch, +} + +fn only_existence(translation: &Translation) -> bool { + translation + .gaps() + .all(|(_, gap)| gap.part == Part::Occurrence && gap.reason == Reason::Existence) +} + +/// Objects whose class the applicability names, counted independently of the +/// engine: the translation selects on entity names only. +fn applicable_objects( + translation: &Translation, + session: &axioval::engine::EvidenceSession, +) -> usize { + let names: Vec<&str> = translation + .definitions + .object_types + .values() + .map(|concept| concept.name.default.as_str()) + .collect(); + session + .project() + .objects() + .filter(|object| { + names + .iter() + .any(|name| object.kind().eq_ignore_ascii_case(name)) + }) + .count() +} + +fn classify(case: &Path) -> Option<(Class, String)> { + let stem = case.file_stem()?.to_str()?; + let expected_pass = if stem.starts_with("pass-") { + true + } else if stem.starts_with("fail-") { + false + } else { + return None; + }; + let ids = openbim_ids::from_slice(&std::fs::read(case).ok()?).expect("corpus IDS reads"); + let options = Options { + package_id: "ids:corpus".into(), + version: "1.0.0".into(), + }; + let translation = translate(&ids, &options).expect("valid options"); + let model = std::fs::read(case.with_extension("ifc")).ok()?; + let session = match import_ifc_session("model.ifc", &model) { + Ok(session) => session, + Err(error) => return Some((Class::ModelRefused, error.to_string())), + }; + let registry = default_registry().expect("built-in registry"); + let plan = compile( + ®istry, + &[translation.definitions.clone()], + &translation.ruleset, + ) + .expect("translated packages compile"); + let report = Runtime::new(registry) + .run_session(&session, plan) + .expect("plan runs"); + let findings = report.findings().len(); + let detail = format!( + "{} finding(s), {} not evaluated, gaps: [{}]", + findings, + report.not_evaluated().len(), + translation + .gaps() + .map(|(_, gap)| gap.to_string()) + .collect::>() + .join("; ") + ); + let class = match (expected_pass, findings > 0) { + (true, true) => Class::Mismatch, + (false, true) => Class::CaughtFail, + _ if !report.not_evaluated().is_empty() => Class::NotEvaluated, + (true, false) if translation.is_complete() => Class::ExactPass, + (true, false) => Class::SoundPass, + (false, false) if translation.is_complete() => Class::Mismatch, + (false, false) if only_existence(&translation) => { + if applicable_objects(&translation, &session) == 0 { + Class::ExistenceFail + } else { + Class::Mismatch + } + } + (false, false) => Class::UnjudgedFail, + }; + Some((class, detail)) +} + +fn run() { + let mut classes: BTreeMap> = BTreeMap::new(); + for case in cases() { + if let Some((class, detail)) = classify(&case) { + let name = case.file_stem().unwrap().to_string_lossy().into_owned(); + classes + .entry(class) + .or_default() + .push(format!("{name}: {detail}")); + } + } + for (class, cases) in &classes { + println!("{class:?}: {}", cases.len()); + if matches!( + class, + Class::CaughtFail + | Class::ExactPass + | Class::NotEvaluated + | Class::ExistenceFail + | Class::ModelRefused + ) { + for case in cases { + println!(" {case}"); + } + } + } + let mismatches = classes.get(&Class::Mismatch).cloned().unwrap_or_default(); + assert!( + mismatches.is_empty(), + "mismatches:\n{}", + mismatches.join("\n") + ); +} + +#[test] +#[ignore = "needs a local buildingSMART IDS checkout in IDS_TEST_CASES"] +fn corpus_translations_never_contradict_the_expected_verdict() { + run(); +} diff --git a/staging/ids/tests/translate.rs b/staging/ids/tests/translate.rs new file mode 100644 index 00000000..cae08b5e --- /dev/null +++ b/staging/ids/tests/translate.rs @@ -0,0 +1,613 @@ +//! Translation rules, and the packages they produce run end to end. +#![allow(missing_docs, clippy::doc_markdown)] + +use axioval::default_registry; +use axioval::engine::{Runtime, compile}; +use axioval::ifc::import_ifc_session; +use axioval::ir::contract::{ParameterValue, RuleApplicability, Selector}; +use axioval::ir::{NotEvaluatedReason, Report}; +use axioval_ids::{ + IFC2X3_TYPE_SYSTEM, IFC4_TYPE_SYSTEM, Options, OptionsError, Part, Reason, Translation, + translate, +}; +use openbim_ids::IfcVersion; + +const HEADER: &str = r#"Ta@b.org"#; + +fn options() -> Options { + Options { + package_id: "ids:test".into(), + version: "1.0.0".into(), + } +} + +/// One specification over `releases`, applying to `applicability` with +/// `occurs` bounds and requiring `requirements`. +fn specification(releases: &str, occurs: &str, applicability: &str, requirements: &str) -> String { + format!( + "{applicability}{requirements}" + ) +} + +fn translate_all(specifications: &[String]) -> Translation { + let text = format!("{HEADER}{}", specifications.concat()); + let ids = openbim_ids::from_str(&text).unwrap_or_else(|error| panic!("{error}\n{text}")); + translate(&ids, &options()).unwrap() +} + +fn one(releases: &str, occurs: &str, applicability: &str, requirements: &str) -> Translation { + translate_all(&[specification(releases, occurs, applicability, requirements)]) +} + +const OPTIONAL: &str = r#"minOccurs="0" maxOccurs="unbounded""#; +const WALL: &str = "IFCWALL"; + +fn property(set: &str, name: &str, attributes: &str) -> String { + format!( + "{set}{name}" + ) +} + +/// A property facet in set `P` with a `` holding `value` (a +/// `simpleValue` or an `xs:restriction`). +fn valued(name: &str, value: &str) -> String { + valued_with("P", name, "", value) +} + +fn valued_with(set: &str, name: &str, attributes: &str, value: &str) -> String { + let value = if value.starts_with('<') { + value.to_owned() + } else { + format!("{value}") + }; + format!( + "{set}{name}{value}" + ) +} + +fn reasons(translation: &Translation) -> Vec<(Part, Reason)> { + translation + .gaps() + .map(|(_, gap)| (gap.part, gap.reason.clone())) + .collect() +} + +#[test] +fn type_systems_are_the_ifc_adapters() { + assert_eq!(IFC2X3_TYPE_SYSTEM, axioval::ifc::IFC2X3_TYPE_SYSTEM); + assert_eq!(IFC4_TYPE_SYSTEM, axioval::ifc::IFC4_TYPE_SYSTEM); +} + +#[test] +fn options_are_checked() { + let ids = openbim_ids::from_str(&format!( + "{HEADER}{}", + specification("IFC4", OPTIONAL, WALL, "") + )) + .unwrap(); + for id in ["test", "Ids:test", "ids:", "ids test:x", ":x"] { + let options = Options { + package_id: id.into(), + ..options() + }; + assert_eq!( + translate(&ids, &options).unwrap_err(), + OptionsError::PackageId(id.into()) + ); + } + for version in ["1.0", "01.0.0", "1.0.x", ""] { + let options = Options { + version: version.into(), + ..options() + }; + assert_eq!( + translate(&ids, &options).unwrap_err(), + OptionsError::Version(version.into()) + ); + } +} + +#[test] +fn a_presence_requirement_becomes_a_property_required_rule() { + let translation = one( + "IFC2X3 IFC4", + OPTIONAL, + WALL, + &property("Pset_WallCommon", "FireRating", ""), + ); + assert!(translation.is_complete(), "{:?}", reasons(&translation)); + let folder = &translation.ruleset.root.folders[0]; + let rule = &folder.rules[0]; + assert_eq!(rule.id, "spec1.facet1"); + assert_eq!( + translation.definitions.definitions[&rule.definition_id].capability, + "axioval:capability.property-required" + ); + // IDS matches the named class only. + let RuleApplicability::Selector(Selector::EntityType { + object_type, + include_subtypes, + }) = &rule.applicability + else { + panic!("{:?}", rule.applicability) + }; + assert!(!include_subtypes); + let wall = &translation.definitions.object_types[object_type]; + let systems: Vec<&str> = wall + .external_names + .iter() + .map(|name| name.type_system.as_str()) + .collect(); + assert_eq!(systems, [IFC2X3_TYPE_SYSTEM, IFC4_TYPE_SYSTEM]); +} + +#[test] +fn applicability_bounds_other_than_optional_are_gaps() { + for (occurs, reason) in [ + ( + "", + Reason::Count { + min: 1, + max: Some(1), + }, + ), + (r#"maxOccurs="unbounded""#, Reason::Existence), + (r#"minOccurs="0" maxOccurs="0""#, Reason::Prohibition), + ( + r#"minOccurs="2" maxOccurs="5""#, + Reason::Count { + min: 2, + max: Some(5), + }, + ), + ] { + let translation = one("IFC4", occurs, WALL, &property("P", "N", "")); + assert_eq!( + reasons(&translation), + [(Part::Occurrence, reason)], + "{occurs}" + ); + // Requirements still hold for every applicable object. + assert_eq!(translation.specifications[0].rules.len(), 1, "{occurs}"); + } +} + +#[test] +fn an_untranslatable_applicability_skips_the_whole_specification() { + for (applicability, reason) in [ + ( + "IFCWALLSHEAR".to_owned(), + Reason::PredefinedType, + ), + ( + "IfcWall".to_owned(), + Reason::EntityCase("IfcWall".into()), + ), + ( + "".to_owned(), + Reason::Restriction, + ), + ( + format!("{WALL}"), + Reason::FacetKind("material"), + ), + ] { + let translation = one("IFC4", OPTIONAL, &applicability, &property("P", "N", "")); + let outcome = &translation.specifications[0]; + assert!(outcome.is_skipped(), "{applicability}"); + assert!(outcome.rules.is_empty(), "{applicability}"); + assert!(translation.ruleset.root.folders.is_empty(), "{applicability}"); + assert!( + outcome.gaps.iter().any(|gap| gap.reason == reason), + "{applicability}: {:?}", + outcome.gaps + ); + } +} + +#[test] +fn only_classes_a_model_session_checks_are_applicable() { + let entity = + |name: &str| format!("{name}"); + let gap = |releases: &str, name: &str| { + let translation = one(releases, OPTIONAL, &entity(name), &property("P", "N", "")); + let outcome = &translation.specifications[0]; + outcome + .gaps + .iter() + .find(|gap| matches!(gap.part, Part::Applicability { .. })) + .map(|gap| (outcome.is_skipped(), gap.reason.clone())) + }; + // A type object is not an occurrence, so rules over it would select nothing. + assert_eq!( + gap("IFC4", "IFCWALLTYPE"), + Some((true, Reason::NotAnObject("IFCWALLTYPE".into()))) + ); + // IfcProject is an IfcObject in IFC2X3 and an IfcContext in IFC4. + assert_eq!(gap("IFC2X3", "IFCPROJECT"), None); + assert_eq!( + gap("IFC2X3 IFC4", "IFCPROJECT"), + Some((true, Reason::NotAnObject("IFCPROJECT".into()))) + ); + assert_eq!( + gap("IFC4", "IFCNOSUCHTHING"), + Some(( + true, + Reason::UnknownEntity { + entity: "IFCNOSUCHTHING".into(), + release: IfcVersion::Ifc4 + } + )) + ); +} + +#[test] +fn an_entity_enumeration_selects_any_of_its_classes() { + let translation = one( + "IFC4", + OPTIONAL, + "", + &property("P", "N", ""), + ); + let rule = &translation.ruleset.root.folders[0].rules[0]; + let RuleApplicability::Selector(Selector::AnyOf { operands }) = &rule.applicability else { + panic!("{:?}", rule.applicability) + }; + assert_eq!(operands.len(), 2); +} + +#[test] +fn requirement_gaps_leave_the_other_requirements_translated() { + let requirements = [ + valued( + "Digits", + "", + ), + valued("Open", ""), + property("P", "Banned", "cardinality=\"prohibited\""), + property("P", "Maybe", "cardinality=\"optional\""), + "Name".to_owned(), + "IFCSLAB".to_owned(), + // Requiring the applicability's own class always holds. + WALL.to_owned(), + property("P", "Present", ""), + ] + .concat(); + let translation = one("IFC4", OPTIONAL, WALL, &requirements); + let requirement = |facet| Part::Requirement { facet }; + assert_eq!( + reasons(&translation), + [ + (requirement(1), Reason::RestrictionFacet("totalDigits")), + (requirement(2), Reason::EmptyRestriction), + (requirement(3), Reason::Prohibited), + (requirement(5), Reason::FacetKind("attribute")), + (requirement(6), Reason::EntityRequirement), + ] + ); + // The optional value-less property and the redundant entity need no rule. + assert_eq!(translation.specifications[0].rules, ["spec1.facet8"]); +} + +#[test] +fn releases_without_a_type_system_are_gaps() { + let translation = one("IFC4X3_ADD2", OPTIONAL, WALL, &property("P", "N", "")); + assert!(translation.specifications[0].is_skipped()); + assert_eq!( + reasons(&translation), + [ + ( + Part::Releases, + Reason::UnsupportedRelease(IfcVersion::Ifc4x3Add2) + ), + (Part::Releases, Reason::NoSupportedRelease), + ] + ); + let mixed = one("IFC4 IFC4X3_ADD2", OPTIONAL, WALL, &property("P", "N", "")); + assert_eq!(mixed.specifications[0].rules.len(), 1); + assert!(!mixed.specifications[0].is_complete()); +} + +#[test] +fn concepts_are_shared_within_a_release_set_and_split_across_them() { + let translation = translate_all(&[ + specification("IFC4", OPTIONAL, WALL, &property("P", "N", "")), + specification("IFC4", OPTIONAL, WALL, &property("P", "N", "")), + specification("IFC2X3 IFC4", OPTIONAL, WALL, &property("P", "N", "")), + ]); + // One wall, property and set per release set. + assert_eq!(translation.definitions.object_types.len(), 2); + assert_eq!(translation.definitions.properties.len(), 2); + assert_eq!(translation.definitions.property_sets.len(), 2); + assert_eq!(translation.definitions.definitions.len(), 1); +} + +#[test] +fn translation_is_deterministic() { + let make = || { + translate_all(&[ + specification("IFC4", OPTIONAL, WALL, &property("P", "A", "")), + specification("IFC2X3", OPTIONAL, WALL, &property("Q", "B", "")), + ]) + }; + let (first, second) = (make(), make()); + assert_eq!( + serde_json::to_string(&first.definitions).unwrap(), + serde_json::to_string(&second.definitions).unwrap() + ); + assert_eq!( + serde_json::to_string(&first.ruleset).unwrap(), + serde_json::to_string(&second.ruleset).unwrap() + ); +} + +/// Two walls and a subtype. `#1` carries the property, `#2` has the set +/// without it, `#3` is an `IfcWallStandardCase` without anything. +const IFC4_MODEL: &str = "ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('n','t',(''),(''),'p','o','a'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCWALL('0000000000000000000001',$,$,$,$,$,$,$,$); +#2=IFCWALL('0000000000000000000002',$,$,$,$,$,$,$,$); +#3=IFCWALLSTANDARDCASE('0000000000000000000003',$,$,$,$,$,$,$,$); +#4=IFCPROPERTYSINGLEVALUE('FireRating',$,IFCLABEL('EI 90'),$); +#5=IFCPROPERTYSET('0000000000000000000004',$,'Pset_WallCommon',$,(#4)); +#6=IFCRELDEFINESBYPROPERTIES('0000000000000000000005',$,$,$,(#1),#5); +#7=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$); +#8=IFCPROPERTYSET('0000000000000000000006',$,'Pset_WallCommon',$,(#7)); +#9=IFCRELDEFINESBYPROPERTIES('0000000000000000000007',$,$,$,(#2),#8); +ENDSEC; +END-ISO-10303-21; +"; + +fn run(translation: &Translation, model: &str) -> Report { + let registry = default_registry().unwrap(); + let plan = compile( + ®istry, + &[translation.definitions.clone()], + &translation.ruleset, + ) + .unwrap(); + let session = import_ifc_session("model.ifc", model.as_bytes()).unwrap(); + Runtime::new(registry).run_session(&session, plan).unwrap() +} + +#[test] +fn translated_rules_check_a_real_model() { + let translation = one( + "IFC4", + OPTIONAL, + WALL, + &property("Pset_WallCommon", "FireRating", ""), + ); + let report = run(&translation, IFC4_MODEL); + assert!( + report.not_evaluated().is_empty(), + "{:?}", + report.not_evaluated() + ); + // #2 lacks the property; #3 is a subclass, which IDS does not apply to. + let flagged: Vec<&str> = report + .findings() + .iter() + .map(|finding| finding.object_id.local_id.as_str()) + .collect(); + assert_eq!(flagged, ["#2"]); +} + +#[test] +fn a_typed_presence_requirement_becomes_a_property_data_type_rule() { + let translation = one( + "IFC4", + OPTIONAL, + WALL, + &property("Pset_WallCommon", "FireRating", "dataType=\"IFCLABEL\""), + ); + assert!(translation.is_complete(), "{:?}", reasons(&translation)); + let rule = &translation.ruleset.root.folders[0].rules[0]; + assert_eq!( + translation.definitions.definitions[&rule.definition_id].capability, + "axioval:capability.property-data-type" + ); + assert_eq!( + rule.parameters["data_type"], + ParameterValue::String { + value: "IFCLABEL".into() + } + ); +} + +#[test] +fn typed_rules_check_the_declared_type_of_a_real_model() { + let flagged = |data_type: &str| { + let attributes = format!("dataType=\"{data_type}\""); + let translation = one( + "IFC4", + OPTIONAL, + WALL, + &property("Pset_WallCommon", "FireRating", &attributes), + ); + let report = run(&translation, IFC4_MODEL); + assert!( + report.not_evaluated().is_empty(), + "{:?}", + report.not_evaluated() + ); + report + .findings() + .iter() + .map(|finding| (finding.object_id.local_id.clone(), finding.message.clone())) + .collect::>() + }; + // #1 states an IFCLABEL; #2 has no FireRating at all. + assert_eq!( + flagged("IFCLABEL"), + [( + "#2".into(), + "missing required property ids:test.property-1".into() + )] + ); + assert_eq!( + flagged("IFCTEXT"), + [ + ( + "#1".into(), + "property ids:test.property-1 is IFCLABEL, not IFCTEXT".into() + ), + ( + "#2".into(), + "missing required property ids:test.property-1".into() + ), + ] + ); +} + +fn only_rule(translation: &Translation) -> &axioval::ir::contract::RuleInstance { + assert!(translation.is_complete(), "{:?}", reasons(translation)); + let rule = &translation.ruleset.root.folders[0].rules[0]; + assert_eq!( + translation.definitions.definitions[&rule.definition_id].capability, + "axioval:capability.property-value" + ); + rule +} + +fn strings(values: &[&str]) -> ParameterValue { + ParameterValue::StringList { + value: values.iter().map(|value| (*value).to_owned()).collect(), + } +} + +#[test] +fn a_simple_value_becomes_a_one_element_value_list() { + let translation = one("IFC4", OPTIONAL, WALL, &valued("Code", "EI 90")); + let rule = only_rule(&translation); + assert_eq!(rule.parameters["values"], strings(&["EI 90"])); + assert!(!rule.parameters.contains_key("optional")); +} + +#[test] +fn restriction_facets_become_their_parameters() { + let translation = one( + "IFC4", + OPTIONAL, + WALL, + &valued_with( + "P", + "Code", + "dataType=\"IFCLABEL\" cardinality=\"optional\"", + "", + ), + ); + let rule = only_rule(&translation); + let text = |value: &str| ParameterValue::String { + value: value.into(), + }; + assert_eq!(rule.parameters["values"], strings(&["A", "B"])); + assert_eq!(rule.parameters["patterns"], strings(&["[AB]"])); + assert_eq!( + rule.parameters["min_length"], + ParameterValue::Integer { value: 1 } + ); + assert_eq!( + rule.parameters["max_length"], + ParameterValue::Integer { value: 2 } + ); + assert_eq!(rule.parameters["data_type"], text("IFCLABEL")); + assert_eq!( + rule.parameters["optional"], + ParameterValue::Boolean { value: true } + ); + + let bounded = one( + "IFC4", + OPTIONAL, + WALL, + &valued( + "Width", + "", + ), + ); + let rule = only_rule(&bounded); + assert_eq!(rule.parameters["min_inclusive"], text("0.2")); + assert_eq!(rule.parameters["max_exclusive"], text("1e1")); +} + +#[test] +fn an_optional_typed_property_checks_its_type_only_when_present() { + let translation = one( + "IFC4", + OPTIONAL, + WALL, + &property( + "P", + "Code", + "dataType=\"IFCLABEL\" cardinality=\"optional\"", + ), + ); + let rule = only_rule(&translation); + assert!(!rule.parameters.contains_key("values")); + assert_eq!( + rule.parameters["optional"], + ParameterValue::Boolean { value: true } + ); +} + +#[test] +fn value_rules_check_a_real_model() { + let flagged = |attributes: &str, value: &str| { + let translation = one( + "IFC4", + OPTIONAL, + WALL, + &valued_with("Pset_WallCommon", "FireRating", attributes, value), + ); + let report = run(&translation, IFC4_MODEL); + assert!( + report.not_evaluated().is_empty(), + "{:?}", + report.not_evaluated() + ); + report + .findings() + .iter() + .map(|finding| finding.object_id.local_id.clone()) + .collect::>() + }; + // #1 states 'EI 90'; #2 has no FireRating. + assert_eq!(flagged("dataType=\"IFCLABEL\"", "EI 90"), ["#2"]); + assert_eq!(flagged("", "EI 60"), ["#1", "#2"]); + assert_eq!(flagged("", "ei 90"), ["#1", "#2"]); + assert_eq!( + flagged( + "", + "" + ), + ["#2"] + ); + // Optional: the absent #2 passes, the present #1 must still match. + assert_eq!(flagged("cardinality=\"optional\"", "EI 60"), ["#1"]); + assert!(flagged("cardinality=\"optional\"", "EI 90").is_empty()); +} + +#[test] +fn a_rule_for_another_release_is_not_evaluated_never_passed() { + let translation = one( + "IFC2X3", + OPTIONAL, + WALL, + &property("Pset_WallCommon", "FireRating", ""), + ); + let report = run(&translation, IFC4_MODEL); + assert!(report.findings().is_empty()); + assert!(!report.not_evaluated().is_empty()); + assert!( + report + .not_evaluated() + .iter() + .all(|outcome| outcome.reason == NotEvaluatedReason::InvalidDeclaration) + ); +}