From dc22edc2cd0972bcd15907d1a6809d901e8a41ba Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:23:26 +0000 Subject: [PATCH 1/7] fix(ifc): do not prove absence over quantity and predefined sets ifc-properties resolves IfcPropertySet members only and skips every other IfcPropertySetDefinition, but the property service turned its "absent" into complete absence evidence. A rule requiring quantity `Foo` in IfcElementQuantity `Foo_Bar` reported a wall as missing it. A per-session index now records the names of skipped definitions and of their members (quantities, nested quantities, predefined-set attributes). An absence one of them could hold is refused as incomplete. The index is model-wide, so it only ever turns an absence into not evaluated. Found by running translated buildingSMART IDS test cases through the adapter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- CHANGELOG.md | 11 ++ crates/sources/semantic/ifc/AGENTS.md | 1 + crates/sources/semantic/ifc/src/ifc.rs | 38 +++-- crates/sources/semantic/ifc/src/lib.rs | 1 + crates/sources/semantic/ifc/src/unread.rs | 139 ++++++++++++++++++ .../semantic/ifc/tests/unread_definitions.rs | 137 +++++++++++++++++ docs/src/adapters.md | 9 ++ 7 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 crates/sources/semantic/ifc/src/unread.rs create mode 100644 crates/sources/semantic/ifc/tests/unread_definitions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1adcec0a..c042d5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,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/sources/semantic/ifc/AGENTS.md b/crates/sources/semantic/ifc/AGENTS.md index a445c93e..3ea68d71 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. - `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..76ff1ad7 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)] @@ -81,6 +82,7 @@ impl TypeHierarchyService for IfcTypeHierarchy { struct IfcPropertyService { model: Arc, snapshots: Arc<[SourceSnapshot]>, + unread: Arc, } impl IfcPropertyService { @@ -173,19 +175,28 @@ impl PropertyResolutionService for IfcPropertyService { 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)), } @@ -275,6 +286,7 @@ pub fn import_ifc_session( let service = PropertyResolutionServiceHandle::new(Arc::new(IfcPropertyService { 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..cb8aed5f --- /dev/null +++ b/crates/sources/semantic/ifc/src/unread.rs @@ -0,0 +1,139 @@ +//! 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. + +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/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..607548a1 100644 --- a/docs/src/adapters.md +++ b/docs/src/adapters.md @@ -13,6 +13,15 @@ snapshot registered by the session; mismatched service composition is rejected. Parser diagnostics, unsupported schemas, malformed traversal, conflicts, and unsupported values fail closed. +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 From 6f8c78c0432ab35daa79fcb772c2c72961eab2d0 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:24:07 +0000 Subject: [PATCH 2/7] feat(staging): translate IDS documents into rule packages axioval-ids reads IDS 1.0 through the openbim-ids reader and writes a definition package and a ruleset. A facet becomes a rule only when an existing capability decides it as IDS does; everything else is a Gap naming the part and the reason. An untranslatable applicability facet leaves its specification without rules, since dropping it would widen the checked population; untranslatable requirements are dropped one by one. Today that is entity applicability (literal or enumeration, subclasses excluded) with value-less, untyped property presence. On the buildingSMART examples 0 of 38 specifications translate completely; the ranked gaps are dataType checks, attribute facets, IFC4X3 and existence. The corpus harness runs the translation through the IFC adapter against the 334 buildingSMART cases: no translated rule fails a pass case, and every unflagged fail case is explained by a gap. A probe without dataType checks found the quantity-absence defect fixed in the previous commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- staging/AGENTS.md | 24 +- staging/ids/Cargo.toml | 36 ++ staging/ids/examples/coverage.rs | 57 +++ staging/ids/src/lib.rs | 796 +++++++++++++++++++++++++++++++ staging/ids/tests/corpus.rs | 234 +++++++++ staging/ids/tests/translate.rs | 362 ++++++++++++++ 6 files changed, 1506 insertions(+), 3 deletions(-) create mode 100644 staging/ids/Cargo.toml create mode 100644 staging/ids/examples/coverage.rs create mode 100644 staging/ids/src/lib.rs create mode 100644 staging/ids/tests/corpus.rs create mode 100644 staging/ids/tests/translate.rs 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..a2cd9800 --- /dev/null +++ b/staging/ids/Cargo.toml @@ -0,0 +1,36 @@ +# 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" } +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..462df27c --- /dev/null +++ b/staging/ids/src/lib.rs @@ -0,0 +1,796 @@ +#![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"; + +/// 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 entity facet naming a predefined type. + PredefinedType, + /// An entity name that is not upper case, which IDS never matches. + EntityCase(String), + /// A value given as an `xs:restriction` where only literals translate. + Restriction, + /// A required value. Comparing it the way IDS does needs the property's + /// IFC defined type, which resolved properties do not carry. + PropertyValue, + /// A `dataType`, which needs the property's IFC defined type. + DataType(String), + /// 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::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("only simple values translate, not restrictions"), + Reason::PropertyValue => f.write_str( + "comparing a value as IDS does needs the property's IFC type, which evidence does not carry", + ), + Reason::DataType(data_type) => write!( + f, + "dataType {data_type} needs the property's IFC type, which evidence does not carry" + ), + 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, &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::PropertyRequired { set, name } = check; + let definition_id = self.property_required_definition(); + let property = self.property(&set, &name, releases); + let property_set = self.property_set(&set, releases); + 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(format!("{set}.{name} is required")), + description, + enabled: true, + severity: Severity::Error, + message: None, + parameters: BTreeMap::from([( + "property".to_owned(), + ParameterValue::PropertyReference { + property, + property_set: Some(property_set), + }, + )]), + applicability: RuleApplicability::Selector(selector.clone()), + requirements: Vec::new(), + citations: Vec::new(), + parameter_citations: Vec::new(), + explanatory_images: Vec::new(), + tags: vec!["ids".to_owned()], + } + } + + fn property_required_definition(&mut self) -> String { + let id = format!("{}.property-required", self.options.package_id); + self.definitions + .entry(id.clone()) + .or_insert_with(|| RuleDefinition { + id: id.clone(), + name: LocalizedText::plain("Property is required"), + description: Some(LocalizedText::plain( + "An IDS property facet without a value: the property must exist with a non-empty value.", + )), + capability: PROPERTY_REQUIRED.to_owned(), + parameters: BTreeMap::from([( + "property".to_owned(), + ParameterDefinition { + id: "property".to_owned(), + name: LocalizedText::plain("Property"), + description: None, + kind: ParameterKind::PropertyReference, + referenced_value_kind: None, + required: true, + default_value: None, + allowed_values: Vec::new(), + unit_dimension: None, + citations: Vec::new(), + }, + )]), + 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 + } +} + +/// One exactly translatable requirement. +enum Check { + /// The property must exist with a non-empty value. + PropertyRequired { set: String, name: String }, +} + +/// 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, + 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).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) +} + +/// 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); + }; + if let Some(data_type) = &property.data_type { + return Err(Reason::DataType(data_type.clone())); + } + if property.value.is_some() { + return Err(Reason::PropertyValue); + } + match occurrence { + Occurrence::Required => Ok(Some(Check::PropertyRequired { + set: set.clone(), + name: name.clone(), + })), + // Without a value, an optional property is satisfied whether or not + // it is there. + Occurrence::Optional => Ok(None), + Occurrence::Prohibited => Err(Reason::Prohibited), + } +} + +/// `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..e5579cac --- /dev/null +++ b/staging/ids/tests/corpus.rs @@ -0,0 +1,234 @@ +//! 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() +} + +/// Drops `dataType` from value-less property requirements. +/// +/// The probe the exact translation cannot run: without the type check the +/// rule is weaker than IDS, so it may miss a failure but must never report +/// one where IDS passes. That still tests presence semantics (null, empty, +/// inherited from the type, quantities) against the corpus. +fn relax(ids: &mut openbim_ids::Ids) { + for specification in &mut ids.specifications { + for requirement in specification + .requirements + .iter_mut() + .flat_map(|requirements| requirements.facets.iter_mut()) + { + if let openbim_ids::Facet::Property(property) = &mut requirement.facet + && property.value.is_none() + { + property.data_type = None; + } + } + } +} + +fn classify(case: &Path, relaxed: bool) -> 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 mut ids = openbim_ids::from_slice(&std::fs::read(case).ok()?).expect("corpus IDS reads"); + if relaxed { + relax(&mut ids); + } + 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, + // A relaxed rule is weaker than IDS: a miss is expected, not a bug. + (false, false) if relaxed => Class::UnjudgedFail, + (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(relaxed: bool) { + let mut classes: BTreeMap> = BTreeMap::new(); + for case in cases() { + if let Some((class, detail)) = classify(&case, relaxed) { + 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(false); +} + +#[test] +#[ignore = "needs a local buildingSMART IDS checkout in IDS_TEST_CASES"] +fn corpus_presence_rules_without_type_checks_never_fail_a_passing_model() { + run(true); +} diff --git a/staging/ids/tests/translate.rs b/staging/ids/tests/translate.rs new file mode 100644 index 00000000..440abac7 --- /dev/null +++ b/staging/ids/tests/translate.rs @@ -0,0 +1,362 @@ +//! 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::{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}" + ) +} + +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 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 = [ + property("P", "Typed", "dataType=\"IFCLABEL\""), + "PValuedx".to_owned(), + 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::DataType("IFCLABEL".into())), + (requirement(2), Reason::PropertyValue), + (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_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) + ); +} From a8c84b0b8ca979f96ae2808a241a8d3aa77fe4f7 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:24:59 +0000 Subject: [PATCH 3/7] docs(ifc): tie the unread-definition guard to its upstream issue Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- crates/sources/semantic/ifc/AGENTS.md | 2 +- crates/sources/semantic/ifc/src/unread.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/sources/semantic/ifc/AGENTS.md b/crates/sources/semantic/ifc/AGENTS.md index 3ea68d71..ca706f4f 100644 --- a/crates/sources/semantic/ifc/AGENTS.md +++ b/crates/sources/semantic/ifc/AGENTS.md @@ -7,7 +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. +- `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/unread.rs b/crates/sources/semantic/ifc/src/unread.rs index cb8aed5f..e4d3b25b 100644 --- a/crates/sources/semantic/ifc/src/unread.rs +++ b/crates/sources/semantic/ifc/src/unread.rs @@ -14,6 +14,9 @@ //! 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; From 56ae4eb962fd62b3e0b5827eedd64e0f16585328 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:46:39 +0000 Subject: [PATCH 4/7] feat(ir,ifc,rules)!: declared property types and property-data-type `Property::data_type` carries the value's type as the source declares it. The IFC session reports it from `ExactProperty::value_type`, upper case whatever case the file used. Unreported stays `None` and serializes as before. `axioval:capability.property-data-type` requires a non-empty property whose declared type equals `data_type`. Another type is a finding; a 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 needs. BREAKING CHANGE: `Property` has a new public field, so struct literals must set it. Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- CHANGELOG.md | 9 ++ crates/contracts/ir/src/lib.rs | 20 ++++ crates/contracts/ir/tests/contracts.rs | 19 +++ crates/engine/rules/AGENTS.md | 2 +- crates/engine/rules/src/lib.rs | 3 +- crates/engine/rules/src/property_rules.rs | 99 +++++++++++++++- crates/engine/rules/tests/properties.rs | 112 +++++++++++++++++- crates/sources/semantic/ifc/src/ifc.rs | 25 ++-- .../sources/semantic/ifc/tests/ifc_session.rs | 3 + docs/src/adapters.md | 3 + docs/src/capabilities.md | 2 + docs/src/ir.md | 2 +- 12 files changed, 282 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c042d5a2..3e4f72d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ 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 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) 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..ce13f3b3 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. `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..dff7e6a3 100644 --- a/crates/engine/rules/src/lib.rs +++ b/crates/engine/rules/src/lib.rs @@ -32,7 +32,7 @@ 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 shelf_capacity::ShelfCapacity; pub use slab_contact::SlabContact; @@ -47,6 +47,7 @@ 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/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/sources/semantic/ifc/src/ifc.rs b/crates/sources/semantic/ifc/src/ifc.rs index 76ff1ad7..0e8f4d2e 100644 --- a/crates/sources/semantic/ifc/src/ifc.rs +++ b/crates/sources/semantic/ifc/src/ifc.rs @@ -160,16 +160,23 @@ 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, 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/docs/src/adapters.md b/docs/src/adapters.md index 607548a1..e8ad8b8d 100644 --- a/docs/src/adapters.md +++ b/docs/src/adapters.md @@ -13,6 +13,9 @@ 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. + 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 diff --git a/docs/src/capabilities.md b/docs/src/capabilities.md index b346505d..c477443d 100644 --- a/docs/src/capabilities.md +++ b/docs/src/capabilities.md @@ -25,6 +25,8 @@ 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-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. From 5d71b5aea8ac73513be87191b3eebfadbab65373 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:46:39 +0000 Subject: [PATCH 5/7] feat(staging): translate required IDS dataType checks A required, value-less property facet with a dataType now becomes a property-data-type rule. A dataType on an optional facet stays a gap: it binds only when the property is present, which no capability checks. On the buildingSMART examples the translation now writes 11 rules instead of 3. Most dataType facets also require a value, so value comparison is now the largest gap (75 facets). The corpus harness still finds no contradiction with dataType checks live, so the relaxed probe that dropped them is removed. Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- staging/ids/src/lib.rs | 173 ++++++++++++++++++++++----------- staging/ids/tests/corpus.rs | 43 +------- staging/ids/tests/translate.rs | 71 +++++++++++++- 3 files changed, 190 insertions(+), 97 deletions(-) diff --git a/staging/ids/src/lib.rs b/staging/ids/src/lib.rs index 462df27c..f56de484 100644 --- a/staging/ids/src/lib.rs +++ b/staging/ids/src/lib.rs @@ -58,6 +58,7 @@ pub const IFC4_TYPE_SYSTEM: &str = "https://identifier.buildingsmart.org/uri/bui 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"; /// Identity of the packages written. #[derive(Clone, Debug, PartialEq, Eq)] @@ -222,11 +223,11 @@ pub enum Reason { EntityCase(String), /// A value given as an `xs:restriction` where only literals translate. Restriction, - /// A required value. Comparing it the way IDS does needs the property's - /// IFC defined type, which resolved properties do not carry. + /// A required value, which no capability compares as IDS does yet. PropertyValue, - /// A `dataType`, which needs the property's IFC defined type. - DataType(String), + /// A `dataType` on an optional facet: the type binds only when the + /// property is present, which no capability checks. + OptionalDataType(String), /// A prohibited facet. Prohibited, /// An entity requirement naming a class other than the applicability's. @@ -252,15 +253,18 @@ impl fmt::Display for Reason { Reason::FacetKind(kind) => write!(f, "no capability decides a {kind} facet"), 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") + write!( + f, + "entity name {name:?} is not upper case, which IDS never matches" + ) } Reason::Restriction => f.write_str("only simple values translate, not restrictions"), - Reason::PropertyValue => f.write_str( - "comparing a value as IDS does needs the property's IFC type, which evidence does not carry", - ), - Reason::DataType(data_type) => write!( + Reason::PropertyValue => { + f.write_str("no capability compares a property value as IDS does yet") + } + Reason::OptionalDataType(data_type) => write!( f, - "dataType {data_type} needs the property's IFC type, which evidence does not carry" + "no capability checks dataType {data_type} only when an optional property is present" ), Reason::Prohibited => f.write_str("no capability decides a prohibited facet"), Reason::EntityRequirement => f.write_str( @@ -445,8 +449,15 @@ impl<'o> Writer<'o> { selector: &Selector, releases: &[IfcVersion], ) -> RuleInstance { - let Check::PropertyRequired { set, name } = check; - let definition_id = self.property_required_definition(); + let (set, name, data_type) = match check { + Check::PropertyRequired { set, name } => (set, name, None), + Check::PropertyDataType { + set, + name, + data_type, + } => (set, name, Some(data_type)), + }; + let definition_id = self.property_definition(data_type.is_some()); let property = self.property(&set, &name, releases); let property_set = self.property_set(&set, releases); let description = requirement @@ -457,18 +468,15 @@ impl<'o> Writer<'o> { RuleInstance { id: format!("spec{number}.facet{facet}"), definition_id, - name: LocalizedText::plain(format!("{set}.{name} is required")), + name: LocalizedText::plain(match &data_type { + Some(data_type) => format!("{set}.{name} is a required {data_type}"), + None => format!("{set}.{name} is required"), + }), description, enabled: true, severity: Severity::Error, message: None, - parameters: BTreeMap::from([( - "property".to_owned(), - ParameterValue::PropertyReference { - property, - property_set: Some(property_set), - }, - )]), + parameters: rule_parameters(property, property_set, data_type), applicability: RuleApplicability::Selector(selector.clone()), requirements: Vec::new(), citations: Vec::new(), @@ -478,35 +486,45 @@ impl<'o> Writer<'o> { } } - fn property_required_definition(&mut self) -> String { - let id = format!("{}.property-required", self.options.package_id); - self.definitions - .entry(id.clone()) - .or_insert_with(|| RuleDefinition { + /// The definition for a presence rule, typed or not, written once. + fn property_definition(&mut self, typed: bool) -> String { + let (suffix, name, description, capability) = if typed { + ( + "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, + ) + } else { + ( + "property-required", + "Property is required", + "An IDS property facet without a value: the property must exist with a non-empty value.", + PROPERTY_REQUIRED, + ) + }; + 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", "Property", ParameterKind::PropertyReference), + )]); + if typed { + parameters.insert( + "data_type".to_owned(), + parameter("data_type", "Data type", ParameterKind::String), + ); + } + RuleDefinition { id: id.clone(), - name: LocalizedText::plain("Property is required"), - description: Some(LocalizedText::plain( - "An IDS property facet without a value: the property must exist with a non-empty value.", - )), - capability: PROPERTY_REQUIRED.to_owned(), - parameters: BTreeMap::from([( - "property".to_owned(), - ParameterDefinition { - id: "property".to_owned(), - name: LocalizedText::plain("Property"), - description: None, - kind: ParameterKind::PropertyReference, - referenced_value_kind: None, - required: true, - default_value: None, - allowed_values: Vec::new(), - unit_dimension: None, - citations: Vec::new(), - }, - )]), + name: LocalizedText::plain(name), + description: Some(LocalizedText::plain(description)), + capability: capability.to_owned(), + parameters, tags: vec!["ids".to_owned()], citations: Vec::new(), - }); + } + }); id } @@ -585,10 +603,50 @@ impl<'o> Writer<'o> { } } +/// A required parameter without default or allowed values. +fn parameter(id: &str, name: &str, kind: ParameterKind) -> ParameterDefinition { + ParameterDefinition { + id: id.to_owned(), + name: LocalizedText::plain(name), + description: None, + kind, + referenced_value_kind: None, + required: true, + default_value: None, + allowed_values: Vec::new(), + unit_dimension: None, + citations: Vec::new(), + } +} + +fn rule_parameters( + property: String, + property_set: String, + data_type: Option, +) -> BTreeMap { + let mut parameters = BTreeMap::from([( + "property".to_owned(), + ParameterValue::PropertyReference { + property, + property_set: Some(property_set), + }, + )]); + if let Some(value) = data_type { + parameters.insert("data_type".to_owned(), ParameterValue::String { value }); + } + parameters +} + /// One exactly translatable requirement. enum Check { /// The property must exist with a non-empty value. PropertyRequired { set: String, name: String }, + /// As `PropertyRequired`, and the source must declare `data_type`. + PropertyDataType { + set: String, + name: String, + data_type: String, + }, } /// The supported releases, recording a gap for each unsupported one. @@ -752,21 +810,22 @@ fn property_check(property: &Property, occurrence: Occurrence) -> Result Ok(Some(Check::PropertyRequired { - set: set.clone(), - name: name.clone(), + let (set, name) = (set.clone(), name.clone()); + match (occurrence, &property.data_type) { + (Occurrence::Required, None) => Ok(Some(Check::PropertyRequired { set, name })), + (Occurrence::Required, Some(data_type)) => Ok(Some(Check::PropertyDataType { + set, + name, + data_type: data_type.clone(), })), - // Without a value, an optional property is satisfied whether or not - // it is there. - Occurrence::Optional => Ok(None), - Occurrence::Prohibited => Err(Reason::Prohibited), + // Without a value or type, an optional property is satisfied + // whether or not it is there. + (Occurrence::Optional, None) => Ok(None), + (Occurrence::Optional, Some(data_type)) => Err(Reason::OptionalDataType(data_type.clone())), + (Occurrence::Prohibited, _) => Err(Reason::Prohibited), } } diff --git a/staging/ids/tests/corpus.rs b/staging/ids/tests/corpus.rs index e5579cac..dcabd72c 100644 --- a/staging/ids/tests/corpus.rs +++ b/staging/ids/tests/corpus.rs @@ -100,29 +100,7 @@ fn applicable_objects( .count() } -/// Drops `dataType` from value-less property requirements. -/// -/// The probe the exact translation cannot run: without the type check the -/// rule is weaker than IDS, so it may miss a failure but must never report -/// one where IDS passes. That still tests presence semantics (null, empty, -/// inherited from the type, quantities) against the corpus. -fn relax(ids: &mut openbim_ids::Ids) { - for specification in &mut ids.specifications { - for requirement in specification - .requirements - .iter_mut() - .flat_map(|requirements| requirements.facets.iter_mut()) - { - if let openbim_ids::Facet::Property(property) = &mut requirement.facet - && property.value.is_none() - { - property.data_type = None; - } - } - } -} - -fn classify(case: &Path, relaxed: bool) -> Option<(Class, String)> { +fn classify(case: &Path) -> Option<(Class, String)> { let stem = case.file_stem()?.to_str()?; let expected_pass = if stem.starts_with("pass-") { true @@ -131,10 +109,7 @@ fn classify(case: &Path, relaxed: bool) -> Option<(Class, String)> { } else { return None; }; - let mut ids = openbim_ids::from_slice(&std::fs::read(case).ok()?).expect("corpus IDS reads"); - if relaxed { - relax(&mut ids); - } + 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(), @@ -172,8 +147,6 @@ fn classify(case: &Path, relaxed: bool) -> Option<(Class, String)> { _ if !report.not_evaluated().is_empty() => Class::NotEvaluated, (true, false) if translation.is_complete() => Class::ExactPass, (true, false) => Class::SoundPass, - // A relaxed rule is weaker than IDS: a miss is expected, not a bug. - (false, false) if relaxed => Class::UnjudgedFail, (false, false) if translation.is_complete() => Class::Mismatch, (false, false) if only_existence(&translation) => { if applicable_objects(&translation, &session) == 0 { @@ -187,10 +160,10 @@ fn classify(case: &Path, relaxed: bool) -> Option<(Class, String)> { Some((class, detail)) } -fn run(relaxed: bool) { +fn run() { let mut classes: BTreeMap> = BTreeMap::new(); for case in cases() { - if let Some((class, detail)) = classify(&case, relaxed) { + if let Some((class, detail)) = classify(&case) { let name = case.file_stem().unwrap().to_string_lossy().into_owned(); classes .entry(class) @@ -224,11 +197,5 @@ fn run(relaxed: bool) { #[test] #[ignore = "needs a local buildingSMART IDS checkout in IDS_TEST_CASES"] fn corpus_translations_never_contradict_the_expected_verdict() { - run(false); -} - -#[test] -#[ignore = "needs a local buildingSMART IDS checkout in IDS_TEST_CASES"] -fn corpus_presence_rules_without_type_checks_never_fail_a_passing_model() { - run(true); + run(); } diff --git a/staging/ids/tests/translate.rs b/staging/ids/tests/translate.rs index 440abac7..b460c5e2 100644 --- a/staging/ids/tests/translate.rs +++ b/staging/ids/tests/translate.rs @@ -206,7 +206,7 @@ fn an_entity_enumeration_selects_any_of_its_classes() { #[test] fn requirement_gaps_leave_the_other_requirements_translated() { let requirements = [ - property("P", "Typed", "dataType=\"IFCLABEL\""), + property("P", "Typed", "dataType=\"IFCLABEL\" cardinality=\"optional\""), "PValuedx".to_owned(), property("P", "Banned", "cardinality=\"prohibited\""), property("P", "Maybe", "cardinality=\"optional\""), @@ -222,7 +222,7 @@ fn requirement_gaps_leave_the_other_requirements_translated() { assert_eq!( reasons(&translation), [ - (requirement(1), Reason::DataType("IFCLABEL".into())), + (requirement(1), Reason::OptionalDataType("IFCLABEL".into())), (requirement(2), Reason::PropertyValue), (requirement(3), Reason::Prohibited), (requirement(5), Reason::FacetKind("attribute")), @@ -342,6 +342,73 @@ fn translated_rules_check_a_real_model() { 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"], + axioval::ir::contract::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() + ), + ] + ); +} + #[test] fn a_rule_for_another_release_is_not_evaluated_never_passed() { let translation = one( From 8a9ee1f6627272c37210a427dd4d3e5ce13aea53 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 13:01:38 +0000 Subject: [PATCH 6/7] feat(ifc,rules): property-value constraints and more IFC value types `axioval:capability.property-value` checks a resolved value against lexical constraints cast to its kind: `values`, XML Schema `patterns` (whole-value, translated exactly or refused), numeric bounds and lengths, optionally a declared `data_type`, and an `optional` mode in which absent and null properties pass. Decimals are equal within the IDS tolerance, boundaries included as the buildingSMART cases require, plus a few ulps for decimal boundaries that are not binary doubles. Bounds compare exactly. A literal or constraint that does not fit the value is not evaluated, never a pass or a violation; quantities wait for units. The IFC session now carries every value whose defined type's base maps without loss (STRING, INTEGER, NUMBER, BOOLEAN; IfcReal), so IfcDate, IfcDuration, IfcTimeStamp and IfcCountMeasure resolve with their declared type instead of being refused. Unit-bearing real measures stay refused. Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- CHANGELOG.md | 9 + crates/engine/rules/AGENTS.md | 2 +- crates/engine/rules/src/lib.rs | 4 + crates/engine/rules/src/property_value.rs | 560 ++++++++++++++++++ crates/engine/rules/src/xsd_pattern.rs | 216 +++++++ crates/engine/rules/tests/property_value.rs | 341 +++++++++++ crates/sources/semantic/ifc/src/ifc.rs | 44 +- .../semantic/ifc/tests/defined_types.rs | 77 +++ docs/src/adapters.md | 5 + docs/src/capabilities.md | 2 + 10 files changed, 1245 insertions(+), 15 deletions(-) create mode 100644 crates/engine/rules/src/property_value.rs create mode 100644 crates/engine/rules/src/xsd_pattern.rs create mode 100644 crates/engine/rules/tests/property_value.rs create mode 100644 crates/sources/semantic/ifc/tests/defined_types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4f72d4..ff970a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ All notable changes are documented here. This project follows Semantic Versionin 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) diff --git a/crates/engine/rules/AGENTS.md b/crates/engine/rules/AGENTS.md index ce13f3b3..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-data-type` adds the source-declared type to that contract; an unreported type is not evaluated, never a match. +`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 dff7e6a3..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::{ @@ -34,6 +36,7 @@ pub use property_comparison::PropertyComparison; pub use property_rules::{ 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; @@ -48,6 +51,7 @@ pub fn register_builtins(registry: CapabilityRegistry) -> Result { + 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/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/src/ifc.rs b/crates/sources/semantic/ifc/src/ifc.rs index 0e8f4d2e..3fb6decd 100644 --- a/crates/sources/semantic/ifc/src/ifc.rs +++ b/crates/sources/semantic/ifc/src/ifc.rs @@ -80,6 +80,7 @@ impl TypeHierarchyService for IfcTypeHierarchy { #[derive(Clone)] struct IfcPropertyService { + release: Release, model: Arc, snapshots: Arc<[SourceSnapshot]>, unread: Arc, @@ -103,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 @@ -133,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 { @@ -291,6 +306,7 @@ 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)), 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/docs/src/adapters.md b/docs/src/adapters.md index e8ad8b8d..ae64d091 100644 --- a/docs/src/adapters.md +++ b/docs/src/adapters.md @@ -15,6 +15,11 @@ 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 diff --git a/docs/src/capabilities.md b/docs/src/capabilities.md index c477443d..f21348d9 100644 --- a/docs/src/capabilities.md +++ b/docs/src/capabilities.md @@ -27,6 +27,8 @@ A capability may return findings and not-evaluated outcomes together when only p `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. From 9ed670e87ebe7f1e1e23aa84aa572fdfae54fc66 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 13:01:38 +0000 Subject: [PATCH 7/7] feat(staging): translate IDS property values; refuse non-object classes A property facet with a value becomes a property-value rule: a simple value is a one-element `values`, and a restriction's enumeration, patterns, bounds and lengths map to their parameters. An optional facet sets `optional`, which also covers an optional dataType. totalDigits, fractionDigits and empty restrictions are gaps. The corpus exposed a silent pass: a specification over IFCWALLTYPE translated completely, but an IFC session makes objects only of IfcObject occurrences, so its rules selected nothing. Applicability classes are now checked against each listed release's schema (ifc-schema); a class that is not an IfcObject there, such as a type object or IFC4's IfcProject, or that the release does not define, is a gap that skips the specification. Corpus: 0 contradictions, 30 fail cases caught (was 6). Examples: 28 rules (was 11); remaining gaps are attributes, existence, IFC4X3 and names given as restrictions. Claude-Session: https://claude.ai/code/session_019MNzJbWbSLjhFXQKs3LhUm --- staging/ids/Cargo.toml | 2 + staging/ids/src/lib.rs | 353 ++++++++++++++++++++++++--------- staging/ids/tests/translate.rs | 196 +++++++++++++++++- 3 files changed, 453 insertions(+), 98 deletions(-) diff --git a/staging/ids/Cargo.toml b/staging/ids/Cargo.toml index a2cd9800..88ba9d36 100644 --- a/staging/ids/Cargo.toml +++ b/staging/ids/Cargo.toml @@ -21,6 +21,8 @@ axioval-ir = { path = "../../crates/contracts/ir" } # [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] diff --git a/staging/ids/src/lib.rs b/staging/ids/src/lib.rs index f56de484..72412323 100644 --- a/staging/ids/src/lib.rs +++ b/staging/ids/src/lib.rs @@ -59,6 +59,7 @@ 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)] @@ -217,17 +218,29 @@ pub enum Reason { 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 value given as an `xs:restriction` where only literals translate. + /// A class, property set or property name given as an `xs:restriction`; + /// only an enumeration of classes translates. Restriction, - /// A required value, which no capability compares as IDS does yet. - PropertyValue, - /// A `dataType` on an optional facet: the type binds only when the - /// property is present, which no capability checks. - OptionalDataType(String), + /// 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. @@ -251,6 +264,13 @@ impl fmt::Display for Reason { }, 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!( @@ -258,14 +278,13 @@ impl fmt::Display for Reason { "entity name {name:?} is not upper case, which IDS never matches" ) } - Reason::Restriction => f.write_str("only simple values translate, not restrictions"), - Reason::PropertyValue => { - f.write_str("no capability compares a property value as IDS does yet") - } - Reason::OptionalDataType(data_type) => write!( - f, - "no capability checks dataType {data_type} only when an optional property is present" + 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", @@ -383,7 +402,7 @@ impl<'o> Writer<'o> { let mut gaps = Vec::new(); let releases = releases(specification, &mut gaps); occurrence(specification, &mut gaps); - let applicability = applicability(specification, &mut gaps); + let applicability = applicability(specification, &releases, &mut gaps); let requirements = specification .requirements .iter() @@ -449,17 +468,31 @@ impl<'o> Writer<'o> { selector: &Selector, releases: &[IfcVersion], ) -> RuleInstance { - let (set, name, data_type) = match check { - Check::PropertyRequired { set, name } => (set, name, None), - Check::PropertyDataType { - set, - name, - data_type, - } => (set, name, Some(data_type)), - }; - let definition_id = self.property_definition(data_type.is_some()); + 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() @@ -468,15 +501,12 @@ impl<'o> Writer<'o> { RuleInstance { id: format!("spec{number}.facet{facet}"), definition_id, - name: LocalizedText::plain(match &data_type { - Some(data_type) => format!("{set}.{name} is a required {data_type}"), - None => format!("{set}.{name} is required"), - }), + name: LocalizedText::plain(title), description, enabled: true, severity: Severity::Error, message: None, - parameters: rule_parameters(property, property_set, data_type), + parameters, applicability: RuleApplicability::Selector(selector.clone()), requirements: Vec::new(), citations: Vec::new(), @@ -486,34 +516,59 @@ impl<'o> Writer<'o> { } } - /// The definition for a presence rule, typed or not, written once. - fn property_definition(&mut self, typed: bool) -> String { - let (suffix, name, description, capability) = if typed { - ( - "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, - ) - } else { - ( + /// 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", "Property", ParameterKind::PropertyReference), + parameter("property", ParameterKind::PropertyReference, true), )]); - if typed { - parameters.insert( - "data_type".to_owned(), - parameter("data_type", "Data type", ParameterKind::String), - ); + 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(), @@ -603,15 +658,15 @@ impl<'o> Writer<'o> { } } -/// A required parameter without default or allowed values. -fn parameter(id: &str, name: &str, kind: ParameterKind) -> ParameterDefinition { +/// A parameter without default or allowed values. +fn parameter(id: &str, kind: ParameterKind, required: bool) -> ParameterDefinition { ParameterDefinition { id: id.to_owned(), - name: LocalizedText::plain(name), + name: LocalizedText::plain(id), description: None, kind, referenced_value_kind: None, - required: true, + required, default_value: None, allowed_values: Vec::new(), unit_dimension: None, @@ -619,34 +674,24 @@ fn parameter(id: &str, name: &str, kind: ParameterKind) -> ParameterDefinition { } } -fn rule_parameters( - property: String, - property_set: String, - data_type: Option, -) -> BTreeMap { - let mut parameters = BTreeMap::from([( - "property".to_owned(), - ParameterValue::PropertyReference { - property, - property_set: Some(property_set), - }, - )]); - if let Some(value) = data_type { - parameters.insert("data_type".to_owned(), ParameterValue::String { value }); - } - parameters +/// One exactly translatable requirement. +struct Check { + set: String, + name: String, + kind: CheckKind, + /// Parameters besides the property reference. + parameters: BTreeMap, } -/// One exactly translatable requirement. -enum Check { - /// The property must exist with a non-empty value. - PropertyRequired { set: String, name: String }, - /// As `PropertyRequired`, and the source must declare `data_type`. - PropertyDataType { - set: String, - name: String, - data_type: String, - }, +/// 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. @@ -714,6 +759,7 @@ fn occurrence(specification: &Specification, gaps: &mut Vec) { /// `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; @@ -728,9 +774,9 @@ fn applicability<'s>( for (index, facet) in facets.iter().enumerate() { let part = Part::Applicability { facet: index + 1 }; let result = match facet { - Facet::Entity(entity) => { - entity_names(entity).map(|names| selected = Some((names, entity))) - } + 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 { @@ -743,6 +789,35 @@ fn applicability<'s>( 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() { @@ -810,23 +885,117 @@ fn property_check(property: &Property, occurrence: Occurrence) -> Result 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 (set, name) = (set.clone(), name.clone()); - match (occurrence, &property.data_type) { - (Occurrence::Required, None) => Ok(Some(Check::PropertyRequired { set, name })), - (Occurrence::Required, Some(data_type)) => Ok(Some(Check::PropertyDataType { - set, - name, - data_type: 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. - (Occurrence::Optional, None) => Ok(None), - (Occurrence::Optional, Some(data_type)) => Err(Reason::OptionalDataType(data_type.clone())), - (Occurrence::Prohibited, _) => Err(Reason::Prohibited), + (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. diff --git a/staging/ids/tests/translate.rs b/staging/ids/tests/translate.rs index b460c5e2..cae08b5e 100644 --- a/staging/ids/tests/translate.rs +++ b/staging/ids/tests/translate.rs @@ -4,7 +4,7 @@ use axioval::default_registry; use axioval::engine::{Runtime, compile}; use axioval::ifc::import_ifc_session; -use axioval::ir::contract::{RuleApplicability, Selector}; +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, @@ -48,6 +48,23 @@ fn property(set: &str, name: &str, attributes: &str) -> String { ) } +/// 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() @@ -188,6 +205,42 @@ fn an_untranslatable_applicability_skips_the_whole_specification() { } } +#[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( @@ -206,8 +259,11 @@ fn an_entity_enumeration_selects_any_of_its_classes() { #[test] fn requirement_gaps_leave_the_other_requirements_translated() { let requirements = [ - property("P", "Typed", "dataType=\"IFCLABEL\" cardinality=\"optional\""), - "PValuedx".to_owned(), + valued( + "Digits", + "", + ), + valued("Open", ""), property("P", "Banned", "cardinality=\"prohibited\""), property("P", "Maybe", "cardinality=\"optional\""), "Name".to_owned(), @@ -222,8 +278,8 @@ fn requirement_gaps_leave_the_other_requirements_translated() { assert_eq!( reasons(&translation), [ - (requirement(1), Reason::OptionalDataType("IFCLABEL".into())), - (requirement(2), Reason::PropertyValue), + (requirement(1), Reason::RestrictionFacet("totalDigits")), + (requirement(2), Reason::EmptyRestriction), (requirement(3), Reason::Prohibited), (requirement(5), Reason::FacetKind("attribute")), (requirement(6), Reason::EntityRequirement), @@ -358,7 +414,7 @@ fn a_typed_presence_requirement_becomes_a_property_data_type_rule() { ); assert_eq!( rule.parameters["data_type"], - axioval::ir::contract::ParameterValue::String { + ParameterValue::String { value: "IFCLABEL".into() } ); @@ -409,6 +465,134 @@ fn typed_rules_check_the_declared_type_of_a_real_model() { ); } +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(