Skip to main content

ifc_validate/where_rule/
registry.rs

1//! Which WHERE rules this validator actually evaluates.
2//!
3//! # The honesty problem this solves
4//!
5//! IFC4 declares hundreds of `WHERE` rules as EXPRESS expressions. This
6//! validator does not have an EXPRESS expression evaluator, so it cannot
7//! check most of them. The tempting design is to check the ones it can and
8//! stay quiet about the rest -- which produces a clean report for a file
9//! nobody fully checked, and a user who believes it.
10//!
11//! Instead every rule is registered with an explicit state. Supported rules
12//! are evaluated; unsupported ones are *reported* as unsupported. A caller
13//! can therefore distinguish "this file is conformant" from "this file did
14//! not trip the subset we implement".
15//!
16//! # Why a registry rather than a list of implemented functions
17//!
18//! Rules get implemented over time. If support were implicit in which
19//! functions exist, nothing would tell a reader which rules are missing --
20//! the absence of code is invisible. The registry makes the gap a data
21//! structure that can be counted, printed, and tested against.
22
23use ifc_schema::{Schema, SchemaVersion};
24
25/// Whether this validator evaluates a given rule.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum Support {
29    /// Implemented and evaluated on every run.
30    Implemented,
31    /// Declared by the schema, not evaluated here.
32    ///
33    /// Carries why, so the report can say something better than "no".
34    Unsupported(&'static str),
35}
36
37/// One registered rule.
38///
39/// The entry is the rule's whole scope: it applies to instances of
40/// [`Self::entity`] *and of its subtypes*, and only under the
41/// [`Self::releases`] whose EXPRESS declares it under this id. The engine
42/// reads both from here, so a rule cannot run on a narrower set of
43/// instances, or under more releases, than its entry states.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct RuleEntry {
46    /// Stable rule id: `<declaring entity>.<label>` exactly as the schema
47    /// writes it (e.g. `IfcExternalReference.WR1`), or `global.<name>` for a
48    /// file-wide rule.
49    pub id: &'static str,
50    /// The entity declaring the rule, or `None` for a global rule.
51    ///
52    /// Instances of its subtypes are constrained too: EXPRESS WHERE rules
53    /// are inherited.
54    pub entity: Option<&'static str>,
55    /// The releases whose EXPRESS declares this rule under [`Self::id`].
56    pub releases: &'static [SchemaVersion],
57    /// Whether it is evaluated.
58    pub support: Support,
59}
60
61impl RuleEntry {
62    /// Whether `schema` is a release that declares this rule.
63    ///
64    /// Tables of no recognised release declare none of the registered
65    /// rules: a rule is never carried across versions, nor onto an unknown
66    /// one.
67    #[must_use]
68    pub fn applies_to(&self, schema: &Schema) -> bool {
69        schema
70            .version()
71            .is_some_and(|version| self.releases.contains(&version))
72    }
73}
74
75/// Reason strings, shared so the same gap reads identically everywhere.
76const NEEDS_EXPRESSIONS: &str = "requires an EXPRESS expression evaluator";
77const NEEDS_INVERSES: &str =
78    "not implemented uniformly: IFC2X3 requires INVERSE relationship semantics, which validation does not derive";
79const NEEDS_GEOMETRY: &str = "requires geometric evaluation, which validation does not perform";
80
81/// Every release the registry is verified against (`tests/registry_scope.rs`).
82/// IFC4X1 and IFC4X2 are bundled but not yet scoped: no rule applies to them.
83const ALL: &[SchemaVersion] = &[
84    SchemaVersion::Ifc2x3,
85    SchemaVersion::Ifc4,
86    SchemaVersion::Ifc4x3,
87];
88/// IFC2X3 alone: IFC4 renamed most of its `WRnn` labels.
89const IFC2X3: &[SchemaVersion] = &[SchemaVersion::Ifc2x3];
90/// IFC4 and IFC4X3, which share their rule labels.
91const IFC4_FAMILY: &[SchemaVersion] = &[SchemaVersion::Ifc4, SchemaVersion::Ifc4x3];
92
93/// Every rule this validator knows about, implemented or not.
94///
95/// Deliberately not exhaustive over every bundled IFC release: claiming to
96/// enumerate all rules would be its own dishonesty. It covers selected
97/// high-value predicates plus representative unsupported categories.
98///
99/// Every entity-scoped entry's id, entity and releases are checked against
100/// the normative EXPRESS by `tests/registry_scope.rs`.
101pub const RULES: &[RuleEntry] = &[
102    RuleEntry {
103        id: "global.IfcSingleProjectInstance",
104        entity: None,
105        releases: ALL,
106        support: Support::Implemented,
107    },
108    // IfcRoot constrains GlobalId with `UNIQUE UR1`, not a WHERE rule. It is
109    // registered here under its global id rather than as `IfcRoot.UR1`: the
110    // check is file-wide, and two entries for one check would double-report.
111    RuleEntry {
112        id: "global.UniqueGlobalId",
113        entity: None,
114        releases: ALL,
115        support: Support::Implemented,
116    },
117    RuleEntry {
118        id: "IfcRelDefinesByProperties.NoRelatedTypeObject",
119        entity: Some("IfcRelDefinesByProperties"),
120        releases: IFC4_FAMILY,
121        support: Support::Implemented,
122    },
123    RuleEntry {
124        id: "IfcExternalReference.WR1",
125        entity: Some("IfcExternalReference"),
126        releases: ALL,
127        support: Support::Implemented,
128    },
129    // One predicate under two labels: IFC2X3 `WR1`, IFC4 on
130    // `AvoidInconsistentSequence`.
131    RuleEntry {
132        id: "IfcRelSequence.WR1",
133        entity: Some("IfcRelSequence"),
134        releases: IFC2X3,
135        support: Support::Implemented,
136    },
137    RuleEntry {
138        id: "IfcRelSequence.AvoidInconsistentSequence",
139        entity: Some("IfcRelSequence"),
140        releases: IFC4_FAMILY,
141        support: Support::Implemented,
142    },
143    // IFC2X3 states these on the abstract IfcRelDecomposes as `WR31`, and
144    // the assignment ones below as `WR1`; neither label is registered yet.
145    RuleEntry {
146        id: "IfcRelAggregates.NoSelfReference",
147        entity: Some("IfcRelAggregates"),
148        releases: IFC4_FAMILY,
149        support: Support::Implemented,
150    },
151    RuleEntry {
152        id: "IfcRelNests.NoSelfReference",
153        entity: Some("IfcRelNests"),
154        releases: IFC4_FAMILY,
155        support: Support::Implemented,
156    },
157    RuleEntry {
158        id: "IfcMaterialLayer.NormalizedPriority",
159        entity: Some("IfcMaterialLayer"),
160        releases: IFC4_FAMILY,
161        support: Support::Implemented,
162    },
163    RuleEntry {
164        id: "IfcRelAssignsToActor.NoSelfReference",
165        entity: Some("IfcRelAssignsToActor"),
166        releases: IFC4_FAMILY,
167        support: Support::Implemented,
168    },
169    RuleEntry {
170        id: "IfcRelAssignsToProcess.NoSelfReference",
171        entity: Some("IfcRelAssignsToProcess"),
172        releases: IFC4_FAMILY,
173        support: Support::Implemented,
174    },
175    RuleEntry {
176        id: "IfcRelAssignsToProduct.NoSelfReference",
177        entity: Some("IfcRelAssignsToProduct"),
178        releases: IFC4_FAMILY,
179        support: Support::Implemented,
180    },
181    // Declared on IfcRelAssignsToGroup, so IfcRelAssignsToGroupByFactor
182    // inherits it rather than declaring its own.
183    RuleEntry {
184        id: "IfcRelAssignsToGroup.NoSelfReference",
185        entity: Some("IfcRelAssignsToGroup"),
186        releases: IFC4_FAMILY,
187        support: Support::Implemented,
188    },
189    RuleEntry {
190        id: "IfcRelConnectsPathElements.NormalizedRelatingPriorities",
191        entity: Some("IfcRelConnectsPathElements"),
192        releases: IFC4_FAMILY,
193        support: Support::Implemented,
194    },
195    RuleEntry {
196        id: "IfcRelConnectsPathElements.NormalizedRelatedPriorities",
197        entity: Some("IfcRelConnectsPathElements"),
198        releases: IFC4_FAMILY,
199        support: Support::Implemented,
200    },
201    RuleEntry {
202        id: "IfcRelSpaceBoundary.CorrectPhysOrVirt",
203        entity: Some("IfcRelSpaceBoundary"),
204        releases: IFC4_FAMILY,
205        support: Support::Implemented,
206    },
207    // `IfcUniqueDefinitionNames` / `IfcUniquePropertySetNames`; IFC2X3
208    // states no such rule.
209    RuleEntry {
210        id: "IfcObject.UniquePropertySetNames",
211        entity: Some("IfcObject"),
212        releases: IFC4_FAMILY,
213        support: Support::Implemented,
214    },
215    RuleEntry {
216        id: "IfcTypeObject.UniquePropertySetNames",
217        entity: Some("IfcTypeObject"),
218        releases: IFC4_FAMILY,
219        support: Support::Implemented,
220    },
221    // One predicate under two labels: IFC2X3 `WR41` over `ObjectTypeOf`,
222    // IFC4 on `ApplicableOccurrence` over `Types`.
223    RuleEntry {
224        id: "IfcTypeProduct.WR41",
225        entity: Some("IfcTypeProduct"),
226        releases: IFC2X3,
227        support: Support::Implemented,
228    },
229    RuleEntry {
230        id: "IfcTypeProduct.ApplicableOccurrence",
231        entity: Some("IfcTypeProduct"),
232        releases: IFC4_FAMILY,
233        support: Support::Implemented,
234    },
235    RuleEntry {
236        id: "IfcDocumentReference.WR1",
237        entity: Some("IfcDocumentReference"),
238        releases: ALL,
239        support: Support::Unsupported(NEEDS_INVERSES),
240    },
241    RuleEntry {
242        id: "IfcRepresentationContextSameWCS",
243        entity: None,
244        releases: ALL,
245        support: Support::Unsupported(NEEDS_GEOMETRY),
246    },
247    // One predicate under two labels: IFC2X3 `WR21`, IFC4 on
248    // `AllPointsSameDim`.
249    RuleEntry {
250        id: "IfcPolyLoop.WR21",
251        entity: Some("IfcPolyLoop"),
252        releases: IFC2X3,
253        support: Support::Unsupported(NEEDS_EXPRESSIONS),
254    },
255    RuleEntry {
256        id: "IfcPolyLoop.AllPointsSameDim",
257        entity: Some("IfcPolyLoop"),
258        releases: IFC4_FAMILY,
259        support: Support::Unsupported(NEEDS_EXPRESSIONS),
260    },
261    RuleEntry {
262        id: "IfcQuantityLength.WR21",
263        entity: Some("IfcQuantityLength"),
264        releases: ALL,
265        support: Support::Unsupported(NEEDS_EXPRESSIONS),
266    },
267    RuleEntry {
268        id: "IfcZone.WR1",
269        entity: Some("IfcZone"),
270        releases: ALL,
271        support: Support::Unsupported(NEEDS_EXPRESSIONS),
272    },
273];
274
275/// The registered entry for `id`, if there is one.
276#[must_use]
277pub fn lookup(id: &str) -> Option<&'static RuleEntry> {
278    RULES.iter().find(|entry| entry.id == id)
279}
280
281/// Every rule this validator does not evaluate.
282pub fn unsupported() -> impl Iterator<Item = &'static RuleEntry> {
283    RULES
284        .iter()
285        .filter(|entry| matches!(entry.support, Support::Unsupported(_)))
286}
287
288/// Every rule this validator does evaluate.
289pub fn implemented() -> impl Iterator<Item = &'static RuleEntry> {
290    RULES
291        .iter()
292        .filter(|entry| matches!(entry.support, Support::Implemented))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    /// Rule ids must be unique: they are what callers suppress on.
300    #[test]
301    fn rule_ids_are_unique() {
302        let mut ids: Vec<&str> = RULES.iter().map(|entry| entry.id).collect();
303        ids.sort_unstable();
304        let count = ids.len();
305        ids.dedup();
306        assert_eq!(ids.len(), count, "duplicate rule id in the registry");
307    }
308
309    /// The registry must actually contain unevaluated rules.
310    ///
311    /// If this ever reports zero, either every IFC4 rule is implemented -- it
312    /// is not -- or rules are being dropped from the registry instead of
313    /// being marked unsupported, which is exactly the dishonesty the registry
314    /// exists to prevent.
315    #[test]
316    fn the_registry_admits_what_it_cannot_check() {
317        assert!(
318            unsupported().count() > 0,
319            "a validator claiming full WHERE-rule coverage is lying"
320        );
321    }
322
323    /// An entity-scoped id names its declaring entity, and that entity
324    /// exists in every release the entry claims.
325    ///
326    /// `IfcRelAssignsToGroupByFactor.NoSelfReference` once named a subtype
327    /// that declares nothing; `tests/registry_scope.rs` additionally checks
328    /// each label against the normative EXPRESS.
329    #[test]
330    fn entity_ids_name_their_declaring_entity_in_every_claimed_release() {
331        for entry in RULES {
332            assert!(!entry.releases.is_empty(), "{} applies nowhere", entry.id);
333            let Some(entity) = entry.entity else {
334                continue;
335            };
336            assert!(
337                entry.id.starts_with(&format!("{entity}.")),
338                "{} is not `{entity}.<label>`",
339                entry.id
340            );
341            for version in entry.releases {
342                let schema = ifc_schema::for_version(*version).expect("bundled tables");
343                assert!(
344                    schema.entity(entity).is_some(),
345                    "{} claims {version:?}, which declares no {entity}",
346                    entry.id
347                );
348            }
349        }
350    }
351
352    /// Tables of an unrecognised release run no registered rule.
353    #[test]
354    fn an_unknown_release_declares_no_registered_rule() {
355        let schema = Schema::from_express("SCHEMA IFC9;\nEND_SCHEMA;\n");
356        assert!(RULES.iter().all(|entry| !entry.applies_to(&schema)));
357    }
358
359    #[test]
360    fn unsupported_boundaries_remain_explicit() {
361        let reasons: Vec<_> = unsupported()
362            .filter_map(|entry| match entry.support {
363                Support::Unsupported(reason) => Some(reason),
364                Support::Implemented => None,
365            })
366            .collect();
367        // Aggregate bounds are checked since #111 (`structure::bounds`), so no
368        // rule may still claim to need them.
369        assert!(
370            reasons
371                .iter()
372                .all(|reason| !reason.contains("aggregate bounds")),
373            "{reasons:?}"
374        );
375        for required in ["EXPRESS expression", "INVERSE"] {
376            assert!(
377                reasons.iter().any(|reason| reason.contains(required)),
378                "missing explicit unsupported boundary for {required}: {reasons:?}"
379            );
380        }
381    }
382}