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