Skip to content
Merged
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ All notable changes are documented here. This project follows Semantic Versionin
Results now carry an `objects` index (kind and GlobalId of every object the
report names). On a real model the summary is 712 bytes against 271 KB of
JSON, so an agent can read the shape first and fetch entries on demand.
- **Declared property types.** `Property::data_type` carries the value's type
as the source declares it, and the IFC session reports it (`IFCLABEL`,
`IFCBOOLEAN`, ...). Unreported is `None` and serializes as before. This is a
breaking change for code that builds `Property` with a struct literal.
- **`axioval:capability.property-data-type`.** A required, non-empty
property whose declared type equals `data_type`. Another type is a finding;
an unreported type is not evaluated. Measure-typed IFC values
(`IFCLENGTHMEASURE`, ...) stay not evaluated while the adapter refuses them
pending unit handling.
- **`axioval:capability.property-value`.** Values, XML Schema patterns,
numeric bounds and lengths, written as lexical strings and cast to the
resolved value's kind; decimals compare with the IDS tolerance, bounds
exactly. An `optional` rule passes absent and `null` properties. Anything
that cannot be applied to a value is not evaluated.
- **More IFC property types.** The IFC session carries every value whose
defined type is `STRING`-, `INTEGER`- or `NUMBER`-based (`IfcDate`,
`IfcDuration`, `IfcTimeStamp`, `IfcCountMeasure`, ...) with its declared type,
instead of refusing it. Unit-bearing real measures are still refused.
- **`axioval check`.** Runs a ruleset over an IFC2X3 or IFC4 model and writes
the report and the model's integrity issues as JSON, and optionally a BCF
2.1 archive (`--bcf`). Exit status separates a clean pass (0), findings (3)
Expand Down Expand Up @@ -100,6 +118,17 @@ All notable changes are documented here. This project follows Semantic Versionin
not duplicates, a wall overlapping a space was not on the envelope, and a
closed deck had no edges to guard. Existing tests used same-winding caps and
never saw it; each service now has a closed-body regression test.
- **A quantity was reported as an exact absence.** `ifc-properties` resolves
`IfcPropertySet` members only and skips quantity sets
(`IfcElementQuantity`) and predefined property sets
(`IfcDoorLiningProperties`, ...), yet the IFC property service turned its
"absent" into complete absence evidence. A rule requiring `Foo` in a
quantity set `Foo_Bar` therefore reported a wall as missing a quantity it
carried. Absence is now refused as incomplete (not evaluated) when the
requested set is one of those definitions, or, for an unqualified request,
when one of them has a member of that name. The check is model-wide, so it
can only turn an absence into not evaluated. Found by running translated
buildingSMART IDS test cases.
- **Classification selectors silently passed over sources.** A
`classification` selector read the project's inline classification list,
which no production adapter fills. Over an IFC model every classification
Expand Down
20 changes: 20 additions & 0 deletions crates/contracts/ir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub evidence: Option<Evidence>,
}
impl Property {
Expand All @@ -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<String>) -> Result<Self, IrError> {
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
Expand Down
19 changes: 19 additions & 0 deletions crates/contracts/ir/tests/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. })));
}
2 changes: 1 addition & 1 deletion crates/engine/rules/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Maintained source-neutral capability policy and shared rule algorithms.

Capabilities may depend on Axioval IR and typed host-service interfaces, never concrete OpenBIM, ICDD, Axiolid, STEP, or vendor CAD types. Source interpretation belongs in adapters.

`property_rules.rs` owns property compliance policy; `selection.rs` owns fail-closed selector evaluation. Both must resolve property values and absences through `PropertyResolutionServiceHandle`. Never infer absence from a missing `Object.properties` entry, and never silently skip an object when a property selector cannot be resolved exactly. `property-exists` means exact presence even when a source value is null or blank; `property-required` is the stronger non-empty contract and treats exact absence, null, and blank text as violations without converting adapter failures into findings.
`property_rules.rs` owns property compliance policy; `selection.rs` owns fail-closed selector evaluation. Both must resolve property values and absences through `PropertyResolutionServiceHandle`. Never infer absence from a missing `Object.properties` entry, and never silently skip an object when a property selector cannot be resolved exactly. `property-exists` means exact presence even when a source value is null or blank; `property-required` is the stronger non-empty contract and treats exact absence, null, and blank text as violations without converting adapter failures into findings. `property-data-type` adds the source-declared type to that contract; an unreported type is not evaluated, never a match. `property_value.rs` owns `property-value`: lexical constraints cast to the resolved value's kind. A literal or constraint that does not fit the value is not evaluated, never a pass or a violation. `xsd_pattern.rs` translates XML Schema patterns; refuse any construct it cannot map exactly rather than approximate it.

`free_floor_circle.rs` and `free_floor_rectangle.rs` own exact grounded vertical-shape profiles. They must request whole-base support and all project objects as candidate obstacles. Missing services, backend failures, and invalid/incomplete proofs emit typed not-evaluated outcomes; they never produce a pass or compliance finding.

Expand Down
7 changes: 6 additions & 1 deletion crates/engine/rules/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -32,8 +34,9 @@ pub use guard_diagnosis::{GuardDefect, GuardDiagnosis};
pub use horizontal_guard::HorizontalGuard;
pub use property_comparison::PropertyComparison;
pub use property_rules::{
BooleanPropertyEquals, PropertyExists, PropertyPredicate, PropertyRequired,
BooleanPropertyEquals, PropertyDataType, PropertyExists, PropertyPredicate, PropertyRequired,
};
pub use property_value::PropertyValueConstraint;
pub use shelf_capacity::ShelfCapacity;
pub use slab_contact::SlabContact;
pub use space_validation::SpaceValidation;
Expand All @@ -47,6 +50,8 @@ pub fn register_builtins(registry: CapabilityRegistry) -> Result<CapabilityRegis
registry
.register(PropertyExists)
.and_then(|registry| registry.register(PropertyRequired))
.and_then(|registry| registry.register(PropertyDataType))
.and_then(|registry| registry.register(PropertyValueConstraint))
.and_then(|registry| registry.register(BooleanPropertyEquals))
.and_then(|registry| registry.register(PropertyPredicate))
.and_then(|registry| registry.register(PropertyComparison))
Expand Down
99 changes: 95 additions & 4 deletions crates/engine/rules/src/property_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,7 @@ impl RuleCapability for PropertyRequired {
};
match service.resolve(&request) {
Ok(PropertyResolution::Present(resolved)) => {
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,
Expand All @@ -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<ParameterDescriptor> {
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::<PropertyResolutionServiceHandle>() 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<bool> {
match rule.parameters.get(name)? {
ParameterValue::Boolean { value } => Some(*value),
Expand Down
Loading
Loading