ifc_lite_server/services/data_model/spatial_invariant.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Structural invariant over a finished spatial-node set, split out of
6//! `spatial.rs` to stay under the module-size ratchet (the same reason
7//! `spatial_tree.rs` and `spatial_elevation.rs` exist).
8//!
9//! #3973 fixed the same defect shape twice, pointwise, in two different
10//! code paths: a dangling `children_ids` entry naming an entity with no
11//! corresponding node in `nodes_map` (first a depth-capped node resurrected
12//! as a fake root, then a rescued orphan's `children_ids` populated straight
13//! from `spatial_children_map` with no filtering). Each fix closed one path
14//! into the tree without ruling the shape out structurally, so this checks
15//! the finished tree instead of any one code path.
16
17use super::super::types::SpatialNode;
18use rustc_hash::FxHashMap;
19use std::collections::VecDeque;
20
21/// Check a finished node set for the dangling-reference shape #3973 fixed
22/// twice pointwise: (a) every id in every node's `children_ids` must resolve
23/// to a node in the set, (b) every node's `parent_id` must either resolve to
24/// a node or be the root sentinel (`0` - never a real STEP entity id), and
25/// (c) the two directions must agree - `A.children_ids` containing `B` and
26/// `B.parent_id == A` are two representations of the same fact. Clause (c)
27/// is the one that actually catches the family: a fake root with a real
28/// parent_id disagreement passes (a) and (b) - it names no nonexistent id
29/// and has a resolvable-or-sentinel parent - while still being wrong.
30///
31/// Called from both `build_spatial_hierarchy`'s own `debug_assert!` and the
32/// test suite (`super::spatial::spatial_hierarchy_consistency_violations`),
33/// so this is the single place either has to change.
34///
35/// #4022 widened this from purely local edge consistency to two global
36/// checks (see [`reachability_and_depth_violations`]): every node must be
37/// reachable from a root by walking `children_ids`, and a reachable node's
38/// `level` must equal its actual depth from that walk. A third candidate
39/// class - requiring exactly one root - is deliberately NOT enforced:
40/// `DISCONNECTED_SITE_AGGREGATES_BUILDING_IFC` in `tests.rs` legitimately
41/// produces two independent `parent_id == 0` nodes (the true `IfcProject`
42/// and a rescued orphan promoted to a fake root because it has no reachable
43/// canonical parent), so a hard root-uniqueness rule would fire on valid
44/// output rather than a defect. A model with a second root that is fully
45/// disconnected from the real tree - internally consistent, reachable
46/// on its own, but never reachable from the real root - still passes every
47/// clause here; ruling that out needs a way to tell a legitimate rescued
48/// island apart from a genuine second tree, which none of the clauses below
49/// (or #3973's fixes) has any information to do.
50pub(crate) fn spatial_hierarchy_consistency_violations(nodes: &[&SpatialNode]) -> Vec<String> {
51 let by_id: FxHashMap<u32, &&SpatialNode> = nodes.iter().map(|n| (n.entity_id, n)).collect();
52 let mut violations = Vec::new();
53
54 for node in nodes {
55 for &child_id in &node.children_ids {
56 match by_id.get(&child_id) {
57 None => violations.push(format!(
58 "node #{} lists child #{child_id} in children_ids, but no node #{child_id} exists",
59 node.entity_id
60 )),
61 Some(child) if child.parent_id != node.entity_id => violations.push(format!(
62 "node #{} lists child #{child_id} in children_ids, but #{child_id}.parent_id is #{} (expected #{})",
63 node.entity_id, child.parent_id, node.entity_id
64 )),
65 Some(_) => {}
66 }
67 }
68
69 if node.parent_id != 0 {
70 match by_id.get(&node.parent_id) {
71 None => violations.push(format!(
72 "node #{} has parent_id #{}, but no node #{} exists",
73 node.entity_id, node.parent_id, node.parent_id
74 )),
75 Some(parent) if !parent.children_ids.contains(&node.entity_id) => {
76 violations.push(format!(
77 "node #{} has parent_id #{}, but #{}.children_ids does not list #{}",
78 node.entity_id, node.parent_id, node.parent_id, node.entity_id
79 ))
80 }
81 Some(_) => {}
82 }
83 }
84 }
85
86 violations.extend(reachability_and_depth_violations(nodes, &by_id));
87
88 violations
89}
90
91/// Global check, added for #4022: BFS every `children_ids` edge starting
92/// from every node whose `parent_id` is the sentinel `0` (there can
93/// legitimately be more than one, see the module doc above), tracking depth
94/// as it goes. Any node the walk never reaches is flagged - this is what
95/// catches a cycle isolated from every root (e.g. two nodes that are each
96/// other's parent and only listed child: each satisfies the local clauses
97/// above, but neither has a `parent_id == 0` node in its ancestry, so
98/// neither is ever enqueued). Any node the walk does reach has its `level`
99/// compared against the depth the walk assigned it, catching a `level` that
100/// disagrees with the node's actual position in the tree.
101///
102/// This does not - and structurally cannot - detect a second root that is
103/// itself internally well-formed and reachable purely from itself: it is
104/// indistinguishable, by this walk, from a legitimate rescued-orphan island
105/// (see the module doc above), so it is out of scope for this clause.
106fn reachability_and_depth_violations(
107 nodes: &[&SpatialNode],
108 by_id: &FxHashMap<u32, &&SpatialNode>,
109) -> Vec<String> {
110 let mut visited: FxHashMap<u32, u16> = FxHashMap::default();
111 let mut queue: VecDeque<(u32, u16)> = nodes
112 .iter()
113 .filter(|n| n.parent_id == 0)
114 .map(|n| (n.entity_id, 0))
115 .collect();
116
117 while let Some((id, depth)) = queue.pop_front() {
118 if visited.contains_key(&id) {
119 continue;
120 }
121 visited.insert(id, depth);
122 if let Some(node) = by_id.get(&id) {
123 for &child_id in &node.children_ids {
124 if !visited.contains_key(&child_id) {
125 queue.push_back((child_id, depth + 1));
126 }
127 }
128 }
129 }
130
131 let mut violations = Vec::new();
132 for node in nodes {
133 match visited.get(&node.entity_id) {
134 None => violations.push(format!(
135 "node #{} is unreachable from any root (a node with parent_id 0) by walking \
136 children_ids - it may be part of a cycle isolated from the rest of the tree",
137 node.entity_id
138 )),
139 Some(&depth) if depth != node.level => violations.push(format!(
140 "node #{} has level {}, but walking children_ids from its root puts it at depth {depth}",
141 node.entity_id, node.level
142 )),
143 Some(_) => {}
144 }
145 }
146
147 violations
148}
149
150#[cfg(test)]
151#[path = "spatial_invariant_tests.rs"]
152mod spatial_invariant_tests;