Skip to main content

ifc_geometry/lower/
session.rs

1//! One recursive lowering session over one shared graph builder.
2//!
3//! # Why a session exists
4//!
5//! Recursive lowering appends to a single session-owned builder, and family
6//! lowerers return [`NodeId`] instead of freezing isolated child graphs
7//! (`tests/lower_session.rs` pins this). That is not a style preference: a [`NodeId`]
8//! is owned by the graph that minted it, so handles from two independently
9//! finished graphs are mutually foreign. Every composite IFC family needs two
10//! children in one graph:
11//!
12//! - `IfcBooleanResult` references two operands,
13//! - `IfcMappedItem` reuses one source under many transforms,
14//! - a B-rep face set shares one surface across many faces,
15//! - `IfcCsgSolid` nests operations arbitrarily deep.
16//!
17//! The session also carries the model and resolved unit scale that every
18//! lowerer needs. Approximation policy is deliberately absent: lowering emits
19//! exact neutral geometry, while execution providers own tessellation tolerance.
20//!
21//! # What it guarantees
22//!
23//! - **One graph.** All nodes land in one builder, so any two lowered results
24//!   are composable.
25//! - **Memoization.** A shared IFC entity lowered under the same frame yields
26//!   the same node instead of a duplicate subtree.
27//! - **Bounded recursion.** Cyclic and over-deep chains produce typed errors
28//!   rather than a stack overflow.
29//! - **Located failures.** Graph construction faults are translated into
30//!   [`GeometryError`] values that name the offending IFC entity.
31
32use std::collections::BTreeMap;
33
34use axiolid_model::{GeometryGraphBuilder, GeometryNode, GraphError, NodeId};
35use ifc_model::{EntityId, Model};
36
37#[cfg(feature = "compile")]
38use crate::constraint::placement::CachedPositionPolicy;
39use crate::constraint::placement::LinearResolution;
40use crate::error::{GeometryError, GeometryResult};
41use crate::lower::{LoweredGeometry, ProvenanceMap};
42use crate::slots::Slots;
43use crate::transform::Transform;
44use crate::units::UnitScale;
45
46/// Recursion budget for chained IFC references.
47///
48/// IFC places no normative limit on placement or mapped-item nesting, so a
49/// budget is the only way to terminate on malformed input that is deep rather
50/// than strictly cyclic.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct SessionLimits {
53    /// Maximum simultaneously active entities in one chain.
54    pub max_depth: usize,
55    /// Maximum elements one file-declared aggregate may materialize.
56    pub max_aggregate_elements: usize,
57}
58
59impl SessionLimits {
60    /// Depth budget used when a caller states no preference.
61    ///
62    /// Real exporter output nests placements a few levels deep; 64 is far
63    /// above observed depth while still terminating quickly on bad input.
64    pub const DEFAULT_MAX_DEPTH: usize = 64;
65
66    /// Aggregate-element budget used when a caller states no preference.
67    ///
68    /// Sized against the largest thing a legitimate file plausibly declares:
69    /// a dense triangulated face set of ~5.5M triangles expands to 16M
70    /// indices, and the largest observed knot vectors are four orders of
71    /// magnitude smaller. 16M `f64` is ~128 MiB, which bounds a single
72    /// aggregate to something a workstation survives while still refusing the
73    /// billion-element declarations that motivate this budget.
74    ///
75    /// This is a refusal threshold, never a truncation point: an aggregate
76    /// over the limit produces [`crate::GeometryError::AggregateTooLarge`]
77    /// naming the entity, and no geometry is emitted for it.
78    pub const DEFAULT_MAX_AGGREGATE_ELEMENTS: usize = 16_777_216;
79}
80
81impl Default for SessionLimits {
82    fn default() -> Self {
83        Self {
84            max_depth: Self::DEFAULT_MAX_DEPTH,
85            max_aggregate_elements: Self::DEFAULT_MAX_AGGREGATE_ELEMENTS,
86        }
87    }
88}
89
90/// What lowering does with an `IfcPolyLoop` face bound that collapses (#46).
91///
92/// A poly loop "collapses" when fewer than three of its implied edges join
93/// two different points, e.g. `(A, A, B, B)`: it encloses no area, so it
94/// cannot bound a face. Authoring tools emit such sliver faces routinely.
95///
96/// The test is exactly the one the refusal applies, so
97/// [`DropAndReport`](Self::DropAndReport) drops precisely the faces that
98/// [`Refuse`](Self::Refuse) would have refused over, and nothing else.
99#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum DegenerateFacePolicy {
102    /// Refuse the whole representation item with
103    /// [`GeometryError::Degenerate`] naming
104    /// the loop. The default: a file that says a face exists is not silently
105    /// contradicted.
106    #[default]
107    Refuse,
108    /// Leave the face out and record it in
109    /// [`ProvenanceMap::dropped_faces`](crate::lower::ProvenanceMap::dropped_faces).
110    ///
111    /// Only a face whose OUTER bound (or only bound) collapses is dropped:
112    /// that face covers no area, so omitting it removes no surface. A
113    /// collapsed inner bound of a face that still has a valid outer bound is
114    /// still refused, because dropping the face there would remove real area.
115    ///
116    /// Dropping a face can leave a shell open; the backend's own closure
117    /// check still decides whether the result is a solid.
118    DropAndReport,
119}
120
121/// Identity of one lowering result, used to deduplicate shared entities.
122///
123/// The frame is part of the key. Two `IfcMappedItem`s reusing one source under
124/// different transforms are different results, and collapsing them would place
125/// geometry at one location only. Floats are keyed by bit pattern so the key is
126/// totally ordered without imposing a tolerance policy: memoization must be an
127/// exact-identity optimization, never a geometric approximation.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
129struct MemoKey {
130    entity: u64,
131    family: &'static str,
132    basis: [[u64; 3]; 3],
133    origin: [u64; 3],
134}
135
136impl MemoKey {
137    fn new(entity: EntityId, family: &'static str, frame: Transform) -> Self {
138        Self {
139            entity: entity.0,
140            family,
141            basis: frame.basis.map(|axis| axis.map(f64::to_bits)),
142            origin: frame.origin.map(f64::to_bits),
143        }
144    }
145}
146
147/// A single recursive lowering pass over one shared graph builder.
148///
149/// Family lowerers take `&mut LoweringSession` and return [`NodeId`]. Only the
150/// public entry point calls [`LoweringSession::finish`].
151#[derive(Debug)]
152pub struct LoweringSession<'a> {
153    model: &'a Model,
154    units: &'a UnitScale,
155    limits: SessionLimits,
156    builder: GeometryGraphBuilder,
157    nodes: usize,
158    memo: BTreeMap<MemoKey, NodeId>,
159    active: Vec<EntityId>,
160    provenance: ProvenanceMap,
161    /// `MappedTo` face set -> its `IfcIndexedTriangleTextureMap`s, built on
162    /// first use. Maps point at face sets, not the other way round, so
163    /// finding a face set's map means a scan; doing it once per session
164    /// keeps lowering linear in the file.
165    texture_maps: Option<BTreeMap<EntityId, Vec<EntityId>>>,
166    /// What each appended node is, structurally, for net lowering (#44).
167    ///
168    /// A boolean operand must be a solid, and the graph validator rejects a
169    /// `Collection` (or an `Instance` of one) there. Net lowering therefore
170    /// has to split a multi-item body into its solid parts, which needs to
171    /// see node kinds before the graph is frozen. Recording them at push time
172    /// costs one small entry per node and avoids re-walking the builder.
173    shapes: BTreeMap<NodeId, NodeShape>,
174    /// Every atomic curve appended, kept for station lowering (#307).
175    ///
176    /// A station names a distance along its basis, and IFC gives a station
177    /// on a tangent discontinuity the PREVIOUS segment's tangent while the
178    /// neutral evaluators read the next one. Seeing where the basis's seams
179    /// are, and its stated length, needs the curve's stored data, which the
180    /// append-only builder does not hand back.
181    curves: BTreeMap<NodeId, AtomicCurve>,
182    /// What to do with a collapsed poly-loop face (#46).
183    face_policy: DegenerateFacePolicy,
184    /// How a product's `IfcLinearPlacement` is resolved (#353).
185    linear: LinearResolution<'a>,
186    /// What to do with a cached `CartesianPosition` once an evaluator is
187    /// supplied (#354).
188    #[cfg(feature = "compile")]
189    cached_positions: CachedPositionPolicy,
190}
191
192/// The structural kind of a node, as net lowering needs it.
193#[derive(Debug, Clone, PartialEq)]
194pub(crate) enum NodeShape {
195    /// Merges its members; not itself a boolean operand.
196    Collection(Vec<NodeId>),
197    /// Reuses `source` under `transform`.
198    Instance {
199        /// The reused node.
200        source: NodeId,
201        /// Local-to-parent transform.
202        transform: axiolid_core::Transform3,
203    },
204    /// A family the graph validator admits as a boolean operand.
205    Solid,
206    /// Anything else: curves, surfaces, profiles, points.
207    Other,
208}
209
210/// An appended atomic curve, 2D or 3D, as stored.
211#[derive(Debug, Clone, PartialEq)]
212pub(crate) enum AtomicCurve {
213    /// A `GeometryNode::Curve2`.
214    Two(axiolid_curve::Curve2),
215    /// A `GeometryNode::Curve3`.
216    Three(axiolid_curve::Curve3),
217}
218
219impl AtomicCurve {
220    fn of(node: &GeometryNode) -> Option<Self> {
221        match node {
222            GeometryNode::Curve2(curve) => Some(Self::Two(curve.clone())),
223            GeometryNode::Curve3(curve) => Some(Self::Three(curve.clone())),
224            _ => None,
225        }
226    }
227}
228
229impl NodeShape {
230    fn of(node: &GeometryNode) -> Self {
231        match node {
232            GeometryNode::Collection(members) => Self::Collection(members.clone()),
233            GeometryNode::Instance(instance) => Self::Instance {
234                source: instance.source,
235                transform: instance.transform,
236            },
237            // Mirrors axiolid-model's `ExpectedReference::Solid`.
238            GeometryNode::Primitive(_)
239            | GeometryNode::HalfSpace(_)
240            | GeometryNode::SolidOperation(_)
241            | GeometryNode::BRep(_)
242            | GeometryNode::PolygonMesh(_)
243            | GeometryNode::TriMesh(_) => Self::Solid,
244            _ => Self::Other,
245        }
246    }
247}
248
249impl<'a> LoweringSession<'a> {
250    /// Open a session with the default recursion budget.
251    pub fn new(model: &'a Model, units: &'a UnitScale) -> Self {
252        Self::with_limits(model, units, SessionLimits::default())
253    }
254
255    /// Open a session with an explicit recursion budget.
256    pub fn with_limits(model: &'a Model, units: &'a UnitScale, limits: SessionLimits) -> Self {
257        Self {
258            model,
259            units,
260            limits,
261            builder: GeometryGraphBuilder::new(),
262            nodes: 0,
263            memo: BTreeMap::new(),
264            active: Vec::new(),
265            provenance: ProvenanceMap::default(),
266            texture_maps: None,
267            shapes: BTreeMap::new(),
268            curves: BTreeMap::new(),
269            face_policy: DegenerateFacePolicy::default(),
270            linear: LinearResolution::cache_only(),
271            #[cfg(feature = "compile")]
272            cached_positions: CachedPositionPolicy::default(),
273        }
274    }
275
276    /// Derive an uncached `IfcLinearPlacement` through `evaluator` (#353).
277    ///
278    /// Builder-style, like [`Self::with_face_policy`], so every product
279    /// entry point that takes the session -- `lower_product_items`,
280    /// `lower_product_representation`, `lower_product_net` and its openings'
281    /// own placements -- places products along an alignment the same way.
282    /// Without it, a session reads only the cached `CartesianPosition` and
283    /// refuses a placement without one, as before. The evaluator is the
284    /// caller's: this crate links no implementation (ADR 0004, ADR 0012).
285    ///
286    /// A cached position is then checked against the derived one unless
287    /// [`Self::with_cached_position_policy`] says to trust it (#354).
288    #[cfg(feature = "compile")]
289    #[must_use]
290    pub fn with_curve_evaluator(
291        mut self,
292        evaluator: &'a dyn axiolid_curve_evaluate_contract::CurveEvaluator,
293    ) -> Self {
294        self.linear = LinearResolution::derive(evaluator, self.cached_positions);
295        self
296    }
297
298    /// Set what an evaluator-taking session does with a cached
299    /// `CartesianPosition` (#354). Without an evaluator it has no effect.
300    #[cfg(feature = "compile")]
301    #[must_use]
302    pub fn with_cached_position_policy(mut self, policy: CachedPositionPolicy) -> Self {
303        self.cached_positions = policy;
304        if let Some(derivation) = &mut self.linear.derivation {
305            derivation.cached = policy;
306        }
307        self
308    }
309
310    /// Whether this session derives linear placements through an evaluator.
311    pub fn derives_linear_placements(&self) -> bool {
312        #[cfg(feature = "compile")]
313        return self.linear.derivation.is_some();
314        #[cfg(not(feature = "compile"))]
315        false
316    }
317
318    /// How this session resolves a product's `IfcLinearPlacement`.
319    pub(crate) fn linear_resolution(&self) -> LinearResolution<'a> {
320        self.linear
321    }
322
323    /// Set what lowering does with a collapsed poly-loop face (#46).
324    ///
325    /// Builder-style so the default constructors stay unchanged:
326    /// `LoweringSession::new(model, units).with_face_policy(policy)`.
327    #[must_use]
328    pub fn with_face_policy(mut self, policy: DegenerateFacePolicy) -> Self {
329        self.face_policy = policy;
330        self
331    }
332
333    /// The degenerate-face policy in force.
334    pub fn face_policy(&self) -> DegenerateFacePolicy {
335        self.face_policy
336    }
337
338    /// Record a face left out under [`DegenerateFacePolicy::DropAndReport`].
339    pub(crate) fn report_dropped_face(&mut self, face: EntityId) {
340        self.provenance.record_dropped_face(face);
341    }
342
343    /// The `IfcIndexedTriangleTextureMap`s whose `MappedTo` is `face_set`, in
344    /// file order. Empty for an untextured face set.
345    pub(crate) fn triangle_texture_maps(&mut self, face_set: EntityId) -> &[EntityId] {
346        let model = self.model;
347        let index = self.texture_maps.get_or_insert_with(|| {
348            let mut index: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
349            // `ids_of_type` yields file order, so each list stays in it.
350            for &map in model.ids_of_type("IFCINDEXEDTRIANGLETEXTUREMAP") {
351                // `MappedTo` is slot 1 in IFC4 and IFC4X3 alike.
352                let target = model
353                    .get(map)
354                    .and_then(|entity| entity.attributes.get(1))
355                    .and_then(ifc_model::Value::as_ref_id);
356                if let Some(target) = target {
357                    index.entry(target).or_default().push(map);
358                }
359            }
360            index
361        });
362        index.get(&face_set).map_or(&[], Vec::as_slice)
363    }
364
365    /// The model being lowered.
366    pub fn model(&self) -> &'a Model {
367        self.model
368    }
369
370    /// The resolved unit scale for this model.
371    pub fn units(&self) -> &'a UnitScale {
372        self.units
373    }
374
375    /// Number of nodes appended so far.
376    ///
377    /// Exposed so tests can assert that a memoized hit appends nothing.
378    pub fn node_count(&self) -> usize {
379        self.nodes
380    }
381
382    /// Append one node, attributing any graph fault to the current entity.
383    pub fn node(&mut self, node: GeometryNode) -> GeometryResult<NodeId> {
384        let source = self.active.last().copied();
385        let shape = NodeShape::of(&node);
386        let curve = AtomicCurve::of(&node);
387        let id = self
388            .builder
389            .push(node)
390            .map_err(|error| graph_error(source.unwrap_or(EntityId(0)), error))?;
391        self.nodes += 1;
392        self.shapes.insert(id, shape);
393        if let Some(curve) = curve {
394            self.curves.insert(id, curve);
395        }
396        if let Some(source) = source {
397            self.provenance.record(id, source);
398        }
399        Ok(id)
400    }
401
402    /// Append one node, attributing any graph fault to `entity`.
403    pub fn node_for(&mut self, entity: EntityId, node: GeometryNode) -> GeometryResult<NodeId> {
404        let shape = NodeShape::of(&node);
405        let curve = AtomicCurve::of(&node);
406        let id = self
407            .builder
408            .push(node)
409            .map_err(|error| graph_error(entity, error))?;
410        self.nodes += 1;
411        self.shapes.insert(id, shape);
412        if let Some(curve) = curve {
413            self.curves.insert(id, curve);
414        }
415        self.provenance.record(id, entity);
416        Ok(id)
417    }
418
419    /// The structural kind of an appended node; `None` for a foreign id.
420    pub(crate) fn shape(&self, node: NodeId) -> Option<&NodeShape> {
421        self.shapes.get(&node)
422    }
423
424    /// The stored data of an appended atomic curve; `None` for any other
425    /// node, a curve relation included.
426    pub(crate) fn atomic_curve(&self, node: NodeId) -> Option<&AtomicCurve> {
427        self.curves.get(&node)
428    }
429
430    /// Source attribution accumulated so far.
431    pub fn provenance(&self) -> &ProvenanceMap {
432        &self.provenance
433    }
434
435    /// Resolve an entity or report the dangling reference against `referrer`.
436    pub fn entity(
437        &self,
438        referrer: EntityId,
439        id: EntityId,
440    ) -> GeometryResult<&'a ifc_model::Entity> {
441        self.model.get(id).ok_or(GeometryError::MissingEntity {
442            referrer,
443            missing: id,
444        })
445    }
446
447    /// Look up a previously lowered result for `entity` under `frame`.
448    pub fn memoized(
449        &self,
450        entity: EntityId,
451        family: &'static str,
452        frame: Transform,
453    ) -> Option<NodeId> {
454        self.memo.get(&MemoKey::new(entity, family, frame)).copied()
455    }
456
457    /// Record the lowered result for `entity` under `frame`.
458    pub fn memoize(
459        &mut self,
460        entity: EntityId,
461        family: &'static str,
462        frame: Transform,
463        node: NodeId,
464    ) {
465        self.memo.insert(MemoKey::new(entity, family, frame), node);
466    }
467
468    /// Mark `entity` as active in the current chain.
469    ///
470    /// Returns [`GeometryError::CyclicChain`] if the entity is already active
471    /// and [`GeometryError::ChainTooDeep`] once the depth budget is exhausted.
472    /// Every successful call must be paired with [`LoweringSession::exit`].
473    pub fn enter(&mut self, entity: EntityId, kind: &'static str) -> GeometryResult<()> {
474        if self.active.contains(&entity) {
475            return Err(GeometryError::CyclicChain { entity, kind });
476        }
477        if self.active.len() >= self.limits.max_depth {
478            return Err(GeometryError::ChainTooDeep {
479                entity,
480                kind,
481                limit: self.limits.max_depth,
482            });
483        }
484        self.active.push(entity);
485        Ok(())
486    }
487
488    /// Release `entity` from the active chain.
489    ///
490    /// Sharing is not recursion: once a subtree is complete the entity must be
491    /// reachable again from a sibling branch.
492    pub fn exit(&mut self, entity: EntityId) {
493        if self.active.last() == Some(&entity) {
494            self.active.pop();
495            return;
496        }
497        debug_assert!(false, "lowering scopes must exit in LIFO order");
498        if let Some(index) = self.active.iter().rposition(|&active| active == entity) {
499            self.active.remove(index);
500        }
501    }
502
503    /// Borrowed attribute view for `entity`.
504    pub fn slots(&self, entity: EntityId) -> GeometryResult<Slots<'a>> {
505        let resolved = self.entity(entity, entity)?;
506        Ok(Slots::new(entity, resolved))
507    }
508
509    /// Upper-cased IFC type name for `entity`.
510    ///
511    /// Dispatch compares against canonical upper-case names because STEP files
512    /// are case-insensitive in practice and exporters disagree.
513    pub fn type_name(&self, entity: EntityId) -> GeometryResult<String> {
514        Ok(self.entity(entity, entity)?.type_name.to_ascii_uppercase())
515    }
516
517    /// Build a typed `Unsupported` error naming the offending entity.
518    pub fn unsupported(
519        &self,
520        entity: EntityId,
521        type_name: &str,
522        detail: &'static str,
523    ) -> GeometryError {
524        GeometryError::Unsupported {
525            entity,
526            type_name: type_name.to_string(),
527            detail,
528        }
529    }
530
531    /// Build a typed `Degenerate` error naming the offending entity.
532    ///
533    /// Structurally impossible geometry is distinct from an unimplemented
534    /// family: the file is understood and the shape does not exist.
535    pub fn degenerate(
536        &self,
537        entity: EntityId,
538        type_name: &str,
539        detail: impl Into<String>,
540    ) -> GeometryError {
541        GeometryError::Degenerate {
542            entity,
543            type_name: type_name.to_string(),
544            detail: detail.into(),
545        }
546    }
547
548    /// Check a file-declared aggregate size against the element budget.
549    ///
550    /// Takes `u128` so an overflowing product (`triangles * 3`) can be
551    /// computed in a wider type and reported honestly instead of wrapping to a
552    /// small number that passes the check. Returns the size as `usize` only
553    /// once it is known to fit.
554    ///
555    /// Call this *before* reserving, not after: the point is to refuse the
556    /// declaration, not to survive the allocation.
557    pub fn check_aggregate(
558        &self,
559        entity: EntityId,
560        type_name: &str,
561        what: &'static str,
562        requested: u128,
563    ) -> GeometryResult<usize> {
564        let limit = self.limits.max_aggregate_elements;
565        if requested > limit as u128 {
566            return Err(GeometryError::AggregateTooLarge {
567                entity,
568                type_name: type_name.to_string(),
569                what,
570                requested,
571                limit,
572            });
573        }
574        Ok(requested as usize)
575    }
576
577    /// Lower a nested operand through the total dispatcher.
578    ///
579    /// Kept on the session so recursive families do not each re-import the
580    /// dispatcher and risk diverging on cycle/limit handling.
581    pub fn lower_operand(&mut self, entity: EntityId, frame: Transform) -> GeometryResult<NodeId> {
582        crate::lower::dispatch::lower_representation_item(self, entity, frame)
583    }
584
585    /// Freeze the graph with `root` as its single output root.
586    pub fn finish(self, root: NodeId) -> GeometryResult<LoweredGeometry> {
587        let entity = self.current_entity();
588        let graph = self
589            .builder
590            .finish(vec![root])
591            .map_err(|error| graph_error(entity, error))?;
592        Ok(LoweredGeometry {
593            graph,
594            root,
595            provenance: self.provenance,
596        })
597    }
598
599    /// Best-effort attribution target for graph faults raised outside a family.
600    fn current_entity(&self) -> EntityId {
601        self.active.last().copied().unwrap_or(EntityId(0))
602    }
603}
604
605/// Translate a graph construction fault into a located IFC error.
606///
607/// A bare [`GraphError`] names a `NodeId`, which is meaningless when debugging
608/// a 500k-entity file; the IFC entity is the addressable unit.
609pub(crate) fn graph_error(entity: EntityId, error: GraphError) -> GeometryError {
610    GeometryError::Degenerate {
611        entity,
612        type_name: "geometry graph".to_string(),
613        detail: error.to_string(),
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn the_memo_key_separates_entity_family_and_frame() {
623        let frame = Transform::identity();
624        let mut moved = Transform::identity();
625        moved.origin = [1.0, 0.0, 0.0];
626
627        let base = MemoKey::new(EntityId(1), "solid", frame);
628        assert_eq!(base, MemoKey::new(EntityId(1), "solid", frame));
629        assert_ne!(base, MemoKey::new(EntityId(2), "solid", frame));
630        assert_ne!(base, MemoKey::new(EntityId(1), "profile", frame));
631        assert_ne!(base, MemoKey::new(EntityId(1), "solid", moved));
632    }
633
634    #[test]
635    fn signed_zero_does_not_alias_positive_zero_in_the_key() {
636        // -0.0 == 0.0 numerically but has a distinct bit pattern. Keying on
637        // bits keeps memoization an exact-identity optimization.
638        let mut negative = Transform::identity();
639        negative.origin = [-0.0, 0.0, 0.0];
640        assert_ne!(
641            MemoKey::new(EntityId(1), "solid", Transform::identity()),
642            MemoKey::new(EntityId(1), "solid", negative)
643        );
644    }
645
646    #[test]
647    fn the_default_aggregate_budget_is_documented() {
648        assert_eq!(
649            SessionLimits::default().max_aggregate_elements,
650            SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS
651        );
652        assert_eq!(SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS, 16_777_216);
653    }
654
655    #[test]
656    fn the_default_depth_budget_is_documented() {
657        assert_eq!(
658            SessionLimits::default().max_depth,
659            SessionLimits::DEFAULT_MAX_DEPTH
660        );
661    }
662}