From 7ca9ae3c0cb98de2c3ce4781566faac4a04950e6 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:23:26 +0000 Subject: [PATCH 1/3] 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 39264b34..a24b84c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,17 @@ All notable changes are documented here. This project follows Semantic Versionin ### Fixed +- **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 9765f507..1e73b9d5 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 77791d7057ea7c1280494a1cce5feaa3557c4af7 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:24:07 +0000 Subject: [PATCH 2/3] 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 | 21 +- 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, 1504 insertions(+), 2 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 0ad4dbc0..c5a05391 100644 --- a/staging/AGENTS.md +++ b/staging/AGENTS.md @@ -1,8 +1,8 @@ # `staging/` Work that depends on **unpublished** crates (Axiolid kernel crates, the -`openbim-bcf` writer), kept deliberately outside the shipping workspace and -outside every release. +`openbim-bcf` writer, the `openbim-ids` reader), kept deliberately outside the +shipping workspace and outside every release. ## Why this exists @@ -68,3 +68,20 @@ All four scenarios were verified to fail the gate before it was trusted. run `cargo test` and `cargo clippy --all-targets` in `staging/bcf/` by hand. When the writer is published, move it to `crates/sinks/bcf/` and give the facade a `bcf` feature, following the steps above. +- `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 2d23ffeb5d338cc0f2360dcf5a76084659e92360 Mon Sep 17 00:00:00 2001 From: generalpawz Date: Sat, 26 Sep 2026 12:24:59 +0000 Subject: [PATCH 3/3] 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;