Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/sources/semantic/ifc/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ IFC source contracts, conformance doubles, and the production IFC2X3/IFC4 STEP e
- `src/relationships.rs` is the exact relationship-selection service. Relationship identities are entity names in the source's own release; end slots come from that release's schema (`ends_of`), never a hand-written table. Every answer carries a `relationship-scan` completeness locator.
- `src/integrity.rs` reports source irregularities through the same `read_instance` reader as selection. Keep them sharing one reader so a warning and a refusal always describe the same instances.
- Absent required ends (`$`) are recorded, not fatal: strict requests refuse, `AbsentEndPolicy::Skip` answers and cites each one, and integrity reports them as warnings. "Required" is per release: `IfcRelSpaceBoundary.RelatedBuildingElement` is optional in IFC2X3 and required in IFC4. Dangling or wrongly shaped ends stay hard refusals and integrity errors. Do not widen skip to cover those.
- `src/unread.rs` indexes the property-set definitions `exact_property` skips (quantity sets, predefined property sets). The property service refuses an absence that one of them could hold. Keep it an index of names only; resolving quantities belongs upstream, and the index goes when openbimrs/ifc#66 lands.
- `src/identity.rs` reads every `IfcRoot` GlobalId once per session. The same scan decides which objects carry the `ifc-globalid` alias and which integrity warnings are raised, so a missing alias always has exactly one warning. Never attach a GlobalId that is invalid or shared, and keep the `to_uuid`/`from_uuid` round trip until openbimrs/ifc#62 lands.
- `src/classifications.rs` maps `ifc-classification` answers onto `ClassificationService`. Never read classification slots here: release-specific names, notations and hierarchy rules are the upstream crate's. An assignment with no stated system stays `system: None`; never infer one from names or locations.
- Integrity cardinality warnings (`CONTAINED_TWICE`, `ZONE_MEMBER_NOT_SPATIAL`) come from `ifc-systems` anomalies; do not re-derive them from raw relationships.
Expand Down
38 changes: 25 additions & 13 deletions crates/sources/semantic/ifc/src/ifc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -81,6 +82,7 @@ impl TypeHierarchyService for IfcTypeHierarchy {
struct IfcPropertyService {
model: Arc<Model>,
snapshots: Arc<[SourceSnapshot]>,
unread: Arc<UnreadDefinitions>,
}

impl IfcPropertyService {
Expand Down Expand Up @@ -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)),
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/sources/semantic/ifc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
142 changes: 142 additions & 0 deletions crates/sources/semantic/ifc/src/unread.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! Property-set definitions exact resolution does not read.
//!
//! `ifc-properties::exact_property` resolves members of `IfcPropertySet` and
//! skips every other `IfcPropertySetDefinition`: quantity sets
//! (`IfcElementQuantity`) and predefined property sets such as
//! `IfcDoorLiningProperties`. Its `Absent` is therefore "in no property set",
//! which is narrower than the complete absence the engine's evidence claims:
//! a quantity named `Foo` in a set named `Foo_Bar` would be reported absent
//! from `Foo_Bar`.
//!
//! This index lets the property service refuse that claim. It records, once
//! per session, the name of every skipped definition and every name a
//! member of one could be looked up by: quantity names (nested complex
//! quantities included) and the attributes a predefined set declares. It is
//! model-wide, not per object, so it can only turn an absence into "not
//! evaluated", never hide a present value.
//!
//! A workaround until upstream resolves or refuses these definitions itself
//! (openbimrs/ifc#66); remove it then.

use std::collections::BTreeSet;

use ifc_model::{EntityId, Model, Value};

use crate::release::Release;

/// Names that an absence claim must not cover.
#[derive(Debug, Default)]
pub(crate) struct UnreadDefinitions {
/// `Name` of every skipped definition.
sets: BTreeSet<String>,
/// Every member name a skipped definition could answer a lookup with.
members: BTreeSet<String>,
}

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<EntityId>,
) {
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<String> {
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<usize> {
names.iter().position(|name| *name == wanted)
}

fn text(value: Option<&Value>) -> Option<String> {
match value? {
Value::Text(text) => Some(text.to_string()),
_ => None,
}
}

fn references(value: Option<&Value>) -> Vec<EntityId> {
match value {
Some(Value::List(items)) => items
.iter()
.filter_map(|item| match item {
Value::Ref(id) => Some(*id),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
137 changes: 137 additions & 0 deletions crates/sources/semantic/ifc/tests/unread_definitions.rs
Original file line number Diff line number Diff line change
@@ -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<PropertyResolution, PropertyResolutionError> {
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::<PropertyResolutionServiceHandle>()
.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::<PropertyResolutionServiceHandle>()
.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:?}"
);
}
}
9 changes: 9 additions & 0 deletions docs/src/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading