Skip to main content

axioval_ir/
lib.rs

1//! Source-neutral semantic model and declarative package contracts.
2#![forbid(unsafe_code)]
3#![allow(
4    missing_docs,
5    clippy::missing_errors_doc,
6    clippy::return_self_not_must_use
7)]
8
9use std::{collections::BTreeMap, fmt};
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14/// Canonical normalized package contract emitted by `axioval/schema`.
15pub mod contract;
16pub use contract::{DefinitionPackage, RuleSetPackage};
17
18/// Calendar dates and date-times with a UTC offset.
19pub mod temporal;
20pub use temporal::{Date, DateTime, TemporalError, TemporalPrecision};
21
22/// Stable identities of findings and not-evaluated outcomes.
23pub mod identity;
24pub use identity::{FindingId, IdentityError, finding_ids, not_evaluated_ids};
25
26/// Reviewers' decisions about findings, kept across re-checks.
27pub mod decision;
28pub use decision::{
29    ChangedFacet, Decision, DecisionBasis, DecisionChange, DecisionError, DecisionStatus,
30    Decisions, EvidenceCheck, FindingDecision,
31};
32
33/// Named tables of measured values reported beside findings.
34pub mod table;
35pub use table::{
36    ReportColumn, ReportColumnKind, ReportRow, ReportTable, ReportTableError, ReportValue,
37};
38
39/// Validation error for source-neutral contracts.
40#[derive(Debug, Error, PartialEq, Eq)]
41pub enum IrError {
42    /// An identity component was blank.
43    #[error("{kind} must not be blank")]
44    Blank { kind: &'static str },
45    /// A project contains an ambiguous identity.
46    #[error("duplicate object id: {0}")]
47    DuplicateObject(ObjectId),
48    /// One object states two identities in the same external scheme.
49    #[error("object {object} has more than one `{scheme}` identity")]
50    ConflictingExternalId { object: ObjectId, scheme: String },
51    /// Two objects of one source claim the same external identity.
52    #[error("external id {} is claimed by both {} and {}", .0.id, .0.first, .0.second)]
53    DuplicateExternalId(Box<ExternalIdClash>),
54    /// A discipline name is not a lowercase token.
55    #[error(
56        "invalid discipline `{0}`: use 1 to 64 lowercase ASCII letters, digits, `-` or `_`, starting with a letter or digit"
57    )]
58    InvalidDiscipline(String),
59    /// Column types were given for a property value that is no table.
60    #[error("column types describe a table value only")]
61    ColumnTypesWithoutTable,
62}
63
64/// Two objects of one source claiming one external id, in identity order.
65#[derive(Debug, PartialEq, Eq)]
66pub struct ExternalIdClash {
67    pub id: ExternalId,
68    pub first: ObjectId,
69    pub second: ObjectId,
70}
71
72fn required(value: impl Into<String>, kind: &'static str) -> Result<String, IrError> {
73    let value = value.into();
74    if value.trim().is_empty() {
75        Err(IrError::Blank { kind })
76    } else {
77        Ok(value)
78    }
79}
80
81/// Stable, source-qualified input identity.
82#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct SourceId {
85    pub system: String,
86    pub document: String,
87}
88impl SourceId {
89    /// Creates a source identity.
90    pub fn new(system: impl Into<String>, document: impl Into<String>) -> Result<Self, IrError> {
91        Ok(Self {
92            system: required(system, "source system")?,
93            document: required(document, "source document")?,
94        })
95    }
96}
97impl fmt::Display for SourceId {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}:{}", self.system, self.document)
100    }
101}
102
103/// Stable identity of an object within a source.
104#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct ObjectId {
107    pub source: SourceId,
108    pub local_id: String,
109}
110impl ObjectId {
111    /// Creates a source-qualified object identity.
112    pub fn new(source: SourceId, local_id: impl Into<String>) -> Result<Self, IrError> {
113        Ok(Self {
114            source,
115            local_id: required(local_id, "object local id")?,
116        })
117    }
118}
119impl fmt::Display for ObjectId {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "{}/{}", self.source, self.local_id)
122    }
123}
124
125/// An identity an object also carries in a scheme outside this engine.
126///
127/// An alias, never a replacement: the engine keys everything on [`ObjectId`].
128/// External ids exist so output formats and cross-revision tools can name an
129/// object the way other software does. The scheme is an adapter-defined label;
130/// the IR attaches no meaning to it beyond uniqueness within one source.
131#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct ExternalId {
134    pub scheme: String,
135    pub value: String,
136}
137impl ExternalId {
138    /// Creates an external identity.
139    pub fn new(scheme: impl Into<String>, value: impl Into<String>) -> Result<Self, IrError> {
140        Ok(Self {
141            scheme: required(scheme, "external id scheme")?,
142            value: required(value, "external id value")?,
143        })
144    }
145}
146impl fmt::Display for ExternalId {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(f, "{}:{}", self.scheme, self.value)
149    }
150}
151
152/// The discipline a source plays in a check, such as `architecture` or
153/// `structure`.
154///
155/// A host declaration about a source, never read from it: IFC carries no
156/// discipline. The name is a lowercase token (`[a-z0-9][a-z0-9_-]{0,63}`), so
157/// two spellings of one discipline cannot silently differ by case or
158/// whitespace, and it compares exactly. The engine attaches no vocabulary;
159/// hosts and packages agree on the names.
160#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
161#[serde(try_from = "String", into = "String")]
162pub struct Discipline(String);
163impl Discipline {
164    /// Longest accepted discipline name, in bytes.
165    pub const MAX_LEN: usize = 64;
166    /// Validates a discipline name.
167    pub fn new(name: impl Into<String>) -> Result<Self, IrError> {
168        let name = name.into();
169        let mut bytes = name.bytes();
170        let valid = name.len() <= Self::MAX_LEN
171            && bytes
172                .next()
173                .is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit())
174            && bytes.all(|byte| {
175                byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_'
176            });
177        if valid {
178            Ok(Self(name))
179        } else {
180            Err(IrError::InvalidDiscipline(name))
181        }
182    }
183    /// The discipline name.
184    #[must_use]
185    pub fn as_str(&self) -> &str {
186        &self.0
187    }
188}
189impl TryFrom<String> for Discipline {
190    type Error = IrError;
191    fn try_from(name: String) -> Result<Self, IrError> {
192        Self::new(name)
193    }
194}
195impl From<Discipline> for String {
196    fn from(discipline: Discipline) -> Self {
197        discipline.0
198    }
199}
200impl fmt::Display for Discipline {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        f.write_str(&self.0)
203    }
204}
205
206/// Physical dimension of a canonical SI quantity.
207///
208/// The value of a quantity is always in the coherent SI unit of its
209/// dimension: metres, square metres, cubic metres, radians, and for
210/// [`QuantityDimension::Other`] the product of SI base units its exponents
211/// name (kilogram, second, kelvin, ...). Two quantities compare only when
212/// their dimensions are equal.
213#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum QuantityDimension {
216    Length,
217    Area,
218    Volume,
219    /// Radians. Dimensionless in SI, but never compared with a plain ratio.
220    PlaneAngle,
221    /// Any other dimension, as SI base-unit exponents in the order length,
222    /// mass, time, electric current, temperature, amount of substance,
223    /// luminous intensity. A thermal transmittance in W/(m²·K) is
224    /// `[0, 1, -3, 0, -1, 0, 0]`.
225    Other {
226        exponents: [i8; 7],
227    },
228}
229
230impl QuantityDimension {
231    /// The dimension with these SI base-unit exponents, named when it has a name.
232    ///
233    /// All-zero exponents are not a dimension: a dimensionless value is a
234    /// plain number, and a plane angle must be named explicitly.
235    #[must_use]
236    pub fn from_exponents(exponents: [i8; 7]) -> Option<Self> {
237        Some(match exponents {
238            [0, 0, 0, 0, 0, 0, 0] => return None,
239            [1, 0, 0, 0, 0, 0, 0] => Self::Length,
240            [2, 0, 0, 0, 0, 0, 0] => Self::Area,
241            [3, 0, 0, 0, 0, 0, 0] => Self::Volume,
242            exponents => Self::Other { exponents },
243        })
244    }
245
246    /// The coherent SI unit symbol, e.g. `m²` or `kg·s⁻³·K⁻¹`.
247    #[must_use]
248    pub fn unit_symbol(self) -> String {
249        const BASE: [&str; 7] = ["m", "kg", "s", "A", "K", "mol", "cd"];
250        let exponents = match self {
251            Self::Length => return "m".into(),
252            Self::Area => return "m²".into(),
253            Self::Volume => return "m³".into(),
254            Self::PlaneAngle => return "rad".into(),
255            Self::Other { exponents } => exponents,
256        };
257        let superscript = |digit: char| match digit {
258            '-' => '⁻',
259            '1' => '¹',
260            '2' => '²',
261            '3' => '³',
262            '4' => '⁴',
263            '5' => '⁵',
264            '6' => '⁶',
265            '7' => '⁷',
266            '8' => '⁸',
267            '9' => '⁹',
268            _ => '⁰',
269        };
270        BASE.iter()
271            .zip(exponents)
272            .filter(|(_, exponent)| *exponent != 0)
273            .map(|(base, exponent)| {
274                if exponent == 1 {
275                    (*base).to_owned()
276                } else {
277                    format!(
278                        "{base}{}",
279                        exponent
280                            .to_string()
281                            .chars()
282                            .map(superscript)
283                            .collect::<String>()
284                    )
285                }
286            })
287            .collect::<Vec<_>>()
288            .join("·")
289    }
290}
291
292/// A value supplied by a source adapter.
293#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
294#[serde(tag = "type", content = "value", rename_all = "snake_case")]
295pub enum PropertyValue {
296    Null,
297    Boolean(bool),
298    Integer(i64),
299    Decimal(f64),
300    Quantity {
301        value: f64,
302        dimension: QuantityDimension,
303    },
304    String(String),
305    /// A calendar day without a time zone, `YYYY-MM-DD` on the wire.
306    Date(Date),
307    /// An instant with the UTC offset it was stated in,
308    /// `YYYY-MM-DDThh:mm:ss[.f]±hh:mm` (or `Z`) on the wire, tagged
309    /// `dateTime` like the camelCase package kinds. A date-time without an
310    /// offset is not representable.
311    #[serde(rename = "dateTime")]
312    DateTime(DateTime),
313    /// Several values of one property, in the order the source states them
314    /// (the presentation layers of an object). Elements are scalar values:
315    /// never `Null` and never a nested list. A comparison against a list
316    /// states whether any or every element must satisfy it; a list is never
317    /// compared as if it were one of its elements.
318    List(Vec<PropertyValue>),
319    /// A range the source states as one value (an IFC bounded value): a
320    /// lower and an upper bound and a set point, each a scalar value, of
321    /// one kind, and at least one of them stated. An unstated bound leaves
322    /// the range open on that side; it is never zero or infinity.
323    Bounded {
324        #[serde(default, skip_serializing_if = "Option::is_none")]
325        lower: Option<Box<PropertyValue>>,
326        #[serde(default, skip_serializing_if = "Option::is_none")]
327        upper: Option<Box<PropertyValue>>,
328        #[serde(default, skip_serializing_if = "Option::is_none")]
329        set_point: Option<Box<PropertyValue>>,
330    },
331    /// Rows mapping a defining value to a defined value (an IFC table
332    /// value), in the order the source states them; at least one row, every
333    /// cell a scalar value.
334    Table(Vec<PropertyTableRow>),
335    /// Another instance of the same source that the value names (an IFC
336    /// attribute referencing an entity), by its source-qualified identity:
337    /// the value is set, and names that instance. It is compared with no
338    /// literal; a comparison with one is undecided, never a match.
339    Reference(ObjectId),
340    /// A quantity measured only to lie within `[lower, upper]`, in the SI
341    /// unit of its dimension, the bounds finite and ordered: a value from a
342    /// tessellated body. It is one value whose exact place is unknown, so a
343    /// comparison it may pass or fail cannot be decided.
344    Measured {
345        lower: f64,
346        upper: f64,
347        dimension: QuantityDimension,
348    },
349    /// A property that groups named member properties and is no value of
350    /// its own (an IFC complex property or physical complex quantity). It
351    /// is present, declares no type and holds no value of any simple type:
352    /// it meets a requirement that it exist, fails one on its declared type
353    /// or its value, and is never compared as one of its members.
354    Complex,
355}
356
357/// One row of a [`PropertyValue::Table`].
358#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
359#[serde(deny_unknown_fields)]
360pub struct PropertyTableRow {
361    /// The defining (independent) value.
362    pub defining: PropertyValue,
363    /// The value it defines.
364    pub defined: PropertyValue,
365}
366
367impl PropertyValue {
368    /// Whether this is one value of its own: neither null nor a list, a
369    /// bounded value, a table or a complex property.
370    #[must_use]
371    pub fn is_scalar(&self) -> bool {
372        !matches!(
373            self,
374            Self::Null | Self::List(_) | Self::Bounded { .. } | Self::Table(_) | Self::Complex
375        )
376    }
377
378    /// The scalar values a composite value states, in order: a list's
379    /// elements, a bounded value's lower bound, upper bound and set point
380    /// as far as stated, and each table row's defining then defined value.
381    /// `None` for a scalar value or null.
382    ///
383    /// A comparison quantified over a composite value compares these; a
384    /// range is more than its stated values, so a capability judging a
385    /// bounded value against bounds must also consider its open ends.
386    #[must_use]
387    pub fn stated_values(&self) -> Option<Vec<&PropertyValue>> {
388        match self {
389            Self::List(elements) => Some(elements.iter().collect()),
390            Self::Bounded {
391                lower,
392                upper,
393                set_point,
394            } => Some(
395                [lower, upper, set_point]
396                    .into_iter()
397                    .flatten()
398                    .map(AsRef::as_ref)
399                    .collect(),
400            ),
401            Self::Table(rows) => Some(
402                rows.iter()
403                    .flat_map(|row| [&row.defining, &row.defined])
404                    .collect(),
405            ),
406            _ => None,
407        }
408    }
409}
410
411/// Provenance and exactness of evidence.
412#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
413#[serde(deny_unknown_fields)]
414pub struct Evidence {
415    pub source: SourceId,
416    pub locator: String,
417    pub exact: bool,
418}
419impl Evidence {
420    /// Creates evidence asserted exact by its adapter.
421    pub fn exact(source: SourceId, locator: impl Into<String>) -> Self {
422        Self {
423            source,
424            locator: locator.into(),
425            exact: true,
426        }
427    }
428}
429
430/// A namespace/code classification.
431#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
432#[serde(deny_unknown_fields)]
433pub struct Classification {
434    pub system: String,
435    pub code: String,
436}
437impl Classification {
438    /// Creates a classification.
439    pub fn new(system: impl Into<String>, code: impl Into<String>) -> Result<Self, IrError> {
440        Ok(Self {
441            system: required(system, "classification system")?,
442            code: required(code, "classification code")?,
443        })
444    }
445}
446
447/// Property set that names an object's own intrinsic attributes.
448///
449/// Sources describe an object partly through named property sets and partly
450/// through fields of the object itself: its name, its long name, its type
451/// label. A property request in this set asks for such a field by the
452/// source's own attribute name (`Name`, `LongName` for IFC), so rules can
453/// check both through one resolver. It is reserved: it names no property set
454/// of any source, and package concept binding passes it through unchanged.
455pub const ATTRIBUTE_SET: &str = "axioval:attributes";
456
457/// Property set that names the attributes of an object's type object.
458///
459/// Where a source types its occurrences by a shared type object (a door
460/// type, a space type), this set reads that object's attributes: `Name` in
461/// this set is the construction type name. An object with no type is exactly
462/// absent; an object with several is a conflict, not a choice. Reserved like
463/// [`ATTRIBUTE_SET`].
464pub const TYPE_ATTRIBUTE_SET: &str = "axioval:type-attributes";
465
466/// Property set that names how an object is presented in its source.
467///
468/// Reserved like [`ATTRIBUTE_SET`]. Property names are matched ignoring
469/// ASCII case:
470///
471/// - [`PRESENTATION_LAYER`] lists the names of the presentation (CAD) layers
472///   the object's shape is assigned to, as a [`PropertyValue::List`] of
473///   strings: every distinct layer, sorted. An object on no layer has none
474///   (an exact absence), unless its source assigns no layer to any object at
475///   all: then absence says nothing about the object, and the source answers
476///   that it records no layers ([`NotEvaluatedReason::NotRecorded`]).
477/// - [`PRESENTATION_TRANSPARENCY`] lists how transparent the object's styled
478///   surfaces are, as a [`PropertyValue::List`] of decimals from `0.0`
479///   (opaque) to `1.0` (fully transparent): every distinct value, ascending.
480///   A comparison states whether `any` or `all` surfaces must satisfy it. An
481///   object with no styled surface has none (an exact absence).
482pub const PRESENTATION_SET: &str = "axioval:presentation";
483
484/// The layer list property in [`PRESENTATION_SET`].
485pub const PRESENTATION_LAYER: &str = "Layer";
486
487/// The surface transparency list property in [`PRESENTATION_SET`].
488pub const PRESENTATION_TRANSPARENCY: &str = "Transparency";
489
490/// Property set that names the material an object is made of.
491///
492/// The material is the object's own, or else the one its type object
493/// carries. An object with no material has none of these properties (an exact
494/// absence); an object with several material assignments is a conflict.
495/// Reserved like [`ATTRIBUTE_SET`]. Property names are matched ignoring ASCII
496/// case:
497///
498/// - [`MATERIAL_KIND`]: how the material is composed, one of
499///   [`MATERIAL_KIND_SINGLE`], [`MATERIAL_KIND_LAYER_SET`],
500///   [`MATERIAL_KIND_CONSTITUENT_SET`], [`MATERIAL_KIND_PROFILE_SET`] or
501///   [`MATERIAL_KIND_LIST`].
502/// - [`MATERIAL_NAME`]: the name of a single material, or of the set.
503/// - [`MATERIAL_CATEGORY`]: the category of a single material.
504/// - [`MATERIAL_TOTAL_THICKNESS`]: the summed layer thickness of a layer set,
505///   a length.
506/// - [`MATERIAL_COUNT`]: the number of layers, constituents, profiles or
507///   listed materials.
508/// - Members, numbered from 1 in the source's order: `Layer<n>.Material`,
509///   `Layer<n>.Thickness` (a length), `Layer<n>.Name` and `Layer<n>.Category`;
510///   `Constituent<n>.Material`, `Constituent<n>.Name`,
511///   `Constituent<n>.Category` and `Constituent<n>.Fraction` (a decimal);
512///   `Profile<n>.Material`, `Profile<n>.Name` and `Profile<n>.Category`;
513///   `Material<n>.Name` and `Material<n>.Category` for a list. `.Material` is
514///   the name of the member's material.
515/// - [`MATERIAL_NAMES`]: every name the material goes by, as a
516///   [`PropertyValue::List`] of strings, distinct and sorted: the material's
517///   or the set's name and category, and each member's name and category and
518///   its material's name and category. Empty names are left out. A selector
519///   asks whether `any` of them is a given name without enumerating members.
520pub const MATERIAL_SET: &str = "axioval:material";
521
522/// The composition property in [`MATERIAL_SET`].
523pub const MATERIAL_KIND: &str = "Kind";
524/// The name property in [`MATERIAL_SET`].
525pub const MATERIAL_NAME: &str = "Name";
526/// The category property in [`MATERIAL_SET`].
527pub const MATERIAL_CATEGORY: &str = "Category";
528/// The total layer thickness property in [`MATERIAL_SET`].
529pub const MATERIAL_TOTAL_THICKNESS: &str = "TotalThickness";
530/// The member count property in [`MATERIAL_SET`].
531pub const MATERIAL_COUNT: &str = "Count";
532/// The list of every name and category in [`MATERIAL_SET`].
533pub const MATERIAL_NAMES: &str = "Names";
534/// [`MATERIAL_KIND`] of one homogeneous material.
535pub const MATERIAL_KIND_SINGLE: &str = "material";
536/// [`MATERIAL_KIND`] of a set of layers with thicknesses.
537pub const MATERIAL_KIND_LAYER_SET: &str = "layer-set";
538/// [`MATERIAL_KIND`] of a set of named constituents.
539pub const MATERIAL_KIND_CONSTITUENT_SET: &str = "constituent-set";
540/// [`MATERIAL_KIND`] of a set of materials with cross-section profiles.
541pub const MATERIAL_KIND_PROFILE_SET: &str = "profile-set";
542/// [`MATERIAL_KIND`] of an unstructured list of materials.
543pub const MATERIAL_KIND_LIST: &str = "list";
544
545/// Property set that states how an object's body is modelled.
546///
547/// Read from the body representation the source authors, never from a mesh:
548/// the kind of each geometric item (an extrusion, a boundary representation,
549/// a tessellation), and for a swept solid its profile, where it sits and the
550/// path it is swept along. Lengths are in metres, angles in radians, and
551/// positions and directions in the source's model coordinates. Reserved like
552/// [`ATTRIBUTE_SET`]. Property names are matched ignoring ASCII case:
553///
554/// - [`BODY_COUNT`]: the number of geometric items, mapped items resolved.
555/// - [`BODY_KINDS`]: every distinct item kind, as a [`PropertyValue::List`]
556///   of strings, sorted. Kinds are `extrusion`, `tapered-extrusion`,
557///   `revolution`, `tapered-revolution`, `directrix-sweep`, `swept-disk`,
558///   `sectioned-spine`, `brep`, `csg`, `csg-primitive`, `half-space`,
559///   `bounding-box`, `tessellation`, `surface-model`, `face`,
560///   `geometric-set`, `curve`, `surface` and `point`.
561/// - [`BODY_MAPPED`]: whether any item is reached through a mapping (a type's
562///   shared geometry placed at the occurrence).
563/// - Items numbered from 1 in the source's order, `Item<n>.` followed by:
564///   `Kind` ([`BODY_KIND`]), `Mapped`, and for a swept-area item the
565///   `Profile.` facts (below), `EndProfile.` for a tapered sweep,
566///   `Placement.OriginX`, `.OriginY`, `.OriginZ` (lengths) and
567///   `Placement.XAxisX` … `Placement.ZAxisZ` (decimals: the solid's axes;
568///   the profile lies in its X-Y plane). An extrusion adds
569///   `Extrusion.Depth` (a length), `Extrusion.DirectionX`, `.DirectionY`,
570///   `.DirectionZ` (a unit vector) and `Extrusion.Inclination` (the angle
571///   between the extrusion's line and the vertical, 0 to π/2); a revolution
572///   adds `Revolution.Angle`, `Revolution.OriginX` … `.OriginZ` and
573///   `Revolution.AxisX` … `.AxisZ`.
574/// - Profile facts: [`BODY_PROFILE_TYPE`] (`rectangle`,
575///   `rounded-rectangle`, `rectangle-hollow`, `circle`, `circle-hollow`,
576///   `ellipse`, `i-shape`, `asymmetric-i-shape`, `l-shape`, `t-shape`,
577///   `u-shape`, `c-shape`, `z-shape`, `trapezium`, `arbitrary-closed`,
578///   `arbitrary-with-voids`, `center-line`, `composite`, `derived`,
579///   `mirrored`), `Name` ([`BODY_PROFILE_NAME`], a catalogue designation),
580///   `PositionX`, `PositionY` and `PositionAngle` where the profile states a
581///   position, and the family's parameters under their dimension names
582///   (`XDim`, `YDim`, `OverallWidth`, `OverallDepth`, `WebThickness`,
583///   `FlangeThickness`, `FilletRadius`, `Radius`, `WallThickness`, …).
584///   An arbitrary closed profile states its outline as
585///   [`BODY_PROFILE_OUTLINE_X`] and [`BODY_PROFILE_OUTLINE_Y`]: two lists
586///   of lengths, the coordinates of its vertices in the profile's X-Y
587///   plane, in the order the source states them, the closing vertex not
588///   repeated. One with voids adds `VoidCount` and, per void,
589///   `Void<n>.OutlineX` and `Void<n>.OutlineY` alike. Only straight edges
590///   are stated this way: an outline with a curved segment is refused,
591///   never approximated by chords, and the profile's other facts stand.
592///   A composite profile states `Count`, `Label` and `Member<n>.` facts; a
593///   derived or mirrored one `Label` and `Parent.` facts. Every
594///   parameterised section except the trapezium is centred on its position
595///   (the centre of its bounding box), its depth along the position's Y.
596/// - Without the `Item<n>.` prefix, a name reads the body's only item. A
597///   body of several items is a conflict for such a name, never a choice.
598///
599/// An object without a body has none of them (an exact absence), and
600/// neither has an item of another kind, a parameter its family does not
601/// have, or one the source leaves unset (even where the schema defines a
602/// default). A body the source states but that cannot be read exactly is
603/// refused, never absent.
604pub const BODY_SET: &str = "axioval:body";
605
606/// The item count in [`BODY_SET`].
607pub const BODY_COUNT: &str = "Count";
608/// The list of distinct item kinds in [`BODY_SET`].
609pub const BODY_KINDS: &str = "Kinds";
610/// The kind of the only item, or of `Item<n>.`, in [`BODY_SET`].
611pub const BODY_KIND: &str = "Kind";
612/// Whether items are reached through a mapping, in [`BODY_SET`].
613pub const BODY_MAPPED: &str = "Mapped";
614/// The profile family of a swept item in [`BODY_SET`].
615pub const BODY_PROFILE_TYPE: &str = "Profile.Type";
616/// The profile's name (catalogue designation) in [`BODY_SET`].
617pub const BODY_PROFILE_NAME: &str = "Profile.Name";
618/// The x coordinates of an arbitrary profile's outline in [`BODY_SET`].
619pub const BODY_PROFILE_OUTLINE_X: &str = "Profile.OutlineX";
620/// The y coordinates of an arbitrary profile's outline in [`BODY_SET`].
621pub const BODY_PROFILE_OUTLINE_Y: &str = "Profile.OutlineY";
622/// [`BODY_KIND`] of a straight extrusion.
623pub const BODY_KIND_EXTRUSION: &str = "extrusion";
624
625/// Property set that names the classes a ruleset's classifications derive.
626///
627/// The property name is the id of a classification the ruleset declares
628/// (`contract::ClassificationDefinition`); the engine answers it, never a
629/// source. A first-match classification's value is a string, an all-match
630/// one's a list of strings; an object no row matches has none (an exact
631/// absence). Names are the ruleset's own and bind to no concept. Reserved
632/// like [`ATTRIBUTE_SET`].
633pub const CLASSIFICATION_SET: &str = "axioval:classification";
634
635/// Whether `set` is one of the reserved sets, which bind to themselves.
636#[must_use]
637pub fn is_reserved_set(set: &str) -> bool {
638    set == ATTRIBUTE_SET
639        || set == TYPE_ATTRIBUTE_SET
640        || set == PRESENTATION_SET
641        || set == MATERIAL_SET
642        || set == BODY_SET
643        || is_derived_set(set)
644}
645
646/// Property set that names values the engine measures from geometry.
647///
648/// Answered by the host's geometry services, never by a source. Each value
649/// is a length, area or volume sure to hold the exact value: a
650/// [`PropertyValue::Quantity`] with exact evidence when measured exactly, a
651/// [`PropertyValue::Measured`] interval with inexact evidence otherwise (a
652/// tessellated body). Names are matched ignoring ASCII case:
653///
654/// - [`MEASURED_EXTENT_X`], [`MEASURED_EXTENT_Y`], [`MEASURED_EXTENT_Z`]:
655///   the body's extent along the world axes, in metres.
656/// - [`MEASURED_BOTTOM`], [`MEASURED_TOP`]: the elevation of its lowest and
657///   highest point, in metres.
658/// - [`MEASURED_AREA`]: its footprint area, overlaps counted once.
659/// - [`MEASURED_VOLUME`]: its enclosed volume.
660/// - [`MEASURED_X`], [`MEASURED_Y`], [`MEASURED_Z`]: the world
661///   coordinates of its placement origin, in metres, as stated exactly.
662/// - `bottom_above_level;path=<steps>` ([`MEASURED_BOTTOM_ABOVE_LEVEL`]):
663///   its bottom above the placement origin of the one level the path
664///   reaches from it. Steps are `,`-separated and written as a `related`
665///   selector's (`Relationship[:forward|backward|either][+]`). No level
666///   reached is an exact absence; levels at different elevations conflict.
667/// - `boundary_area;kind=<kind>[;plane=<metres>]`
668///   ([`MEASURED_BOUNDARY_AREA`]): a space's summed space-boundary area
669///   against elements of the source kind `kind` or a subtype, boundaries
670///   counted on the body's face planes within `plane` (default 0).
671/// - [`MEASURED_LEVEL_HEIGHT`]: a storey's height to the next storey of
672///   the same spatial parent, stated by the source rather than measured;
673///   none for the highest storey.
674///
675/// A run without the service a value needs cannot measure it for any
676/// object (a missing service, reported once per rule and source). Reserved
677/// like [`ATTRIBUTE_SET`].
678pub const MEASURED_SET: &str = "axioval:measured";
679/// The extent along the world x axis in [`MEASURED_SET`].
680pub const MEASURED_EXTENT_X: &str = "extent_x";
681/// The extent along the world y axis in [`MEASURED_SET`].
682pub const MEASURED_EXTENT_Y: &str = "extent_y";
683/// The vertical extent (height) in [`MEASURED_SET`].
684pub const MEASURED_EXTENT_Z: &str = "extent_z";
685/// The lowest elevation in [`MEASURED_SET`].
686pub const MEASURED_BOTTOM: &str = "bottom";
687/// The highest elevation in [`MEASURED_SET`].
688pub const MEASURED_TOP: &str = "top";
689/// The footprint area in [`MEASURED_SET`].
690pub const MEASURED_AREA: &str = "area";
691/// The enclosed volume in [`MEASURED_SET`].
692pub const MEASURED_VOLUME: &str = "volume";
693/// The x coordinate of the placement origin in [`MEASURED_SET`].
694pub const MEASURED_X: &str = "x";
695/// The y coordinate of the placement origin in [`MEASURED_SET`].
696pub const MEASURED_Y: &str = "y";
697/// The z coordinate of the placement origin in [`MEASURED_SET`].
698pub const MEASURED_Z: &str = "z";
699/// The bottom above a level reached by a path in [`MEASURED_SET`]; takes
700/// `path`.
701pub const MEASURED_BOTTOM_ABOVE_LEVEL: &str = "bottom_above_level";
702/// The space-boundary area against one kind of element in
703/// [`MEASURED_SET`]; takes `kind` and optionally `plane`.
704pub const MEASURED_BOUNDARY_AREA: &str = "boundary_area";
705/// A storey's height to the next storey in [`MEASURED_SET`].
706pub const MEASURED_LEVEL_HEIGHT: &str = "level_height";
707/// Every name in [`MEASURED_SET`] that takes no parameter.
708pub const MEASURED_NAMES: [&str; 11] = [
709    MEASURED_EXTENT_X,
710    MEASURED_EXTENT_Y,
711    MEASURED_EXTENT_Z,
712    MEASURED_BOTTOM,
713    MEASURED_TOP,
714    MEASURED_AREA,
715    MEASURED_VOLUME,
716    MEASURED_X,
717    MEASURED_Y,
718    MEASURED_Z,
719    MEASURED_LEVEL_HEIGHT,
720];
721
722/// Whether `set` is a reserved set the engine derives rather than a source
723/// states: its property names are engine or ruleset vocabulary and bind to
724/// no concept.
725#[must_use]
726pub fn is_derived_set(set: &str) -> bool {
727    set == CLASSIFICATION_SET || set == MEASURED_SET
728}
729
730/// A named semantic property.
731#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
732#[serde(deny_unknown_fields)]
733pub struct Property {
734    pub property_set: String,
735    pub name: String,
736    pub value: PropertyValue,
737    /// The value's type as the source declares it, in the source's own
738    /// vocabulary (an IFC property: `IFCLABEL`, `IFCLENGTHMEASURE`). `None`
739    /// when the source declares none or the adapter does not report it,
740    /// which is never evidence of any particular type.
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub data_type: Option<String>,
743    /// A table value's column types as the source declares them, which
744    /// `data_type` cannot state when the columns differ. `None` when the
745    /// value is no table or the adapter does not report them.
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub column_types: Option<PropertyColumnTypes>,
748    pub evidence: Option<Evidence>,
749}
750
751/// The declared types of a table value's two columns, in the source's own
752/// vocabulary (an IFC table value: `IFCLABEL` and `IFCLENGTHMEASURE`).
753#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
754#[serde(deny_unknown_fields)]
755pub struct PropertyColumnTypes {
756    /// The type of every defining value.
757    pub defining: String,
758    /// The type of every defined value.
759    pub defined: String,
760}
761impl Property {
762    /// Creates a property without provenance.
763    pub fn new(
764        property_set: impl Into<String>,
765        name: impl Into<String>,
766        value: PropertyValue,
767    ) -> Result<Self, IrError> {
768        Ok(Self {
769            property_set: required(property_set, "property set")?,
770            name: required(name, "property name")?,
771            value,
772            data_type: None,
773            column_types: None,
774            evidence: None,
775        })
776    }
777    /// Records the value's type as the source declares it.
778    ///
779    /// # Errors
780    ///
781    /// Returns an error when `data_type` is blank.
782    pub fn with_data_type(mut self, data_type: impl Into<String>) -> Result<Self, IrError> {
783        self.data_type = Some(required(data_type, "property data type")?);
784        Ok(self)
785    }
786    /// Records the declared types of a table value's columns.
787    ///
788    /// # Errors
789    ///
790    /// Returns an error when the value is not a table or a type is blank.
791    pub fn with_column_types(
792        mut self,
793        defining: impl Into<String>,
794        defined: impl Into<String>,
795    ) -> Result<Self, IrError> {
796        if !matches!(self.value, PropertyValue::Table(_)) {
797            return Err(IrError::ColumnTypesWithoutTable);
798        }
799        self.column_types = Some(PropertyColumnTypes {
800            defining: required(defining, "property column type")?,
801            defined: required(defined, "property column type")?,
802        });
803        Ok(self)
804    }
805    /// Attaches source evidence.
806    pub fn with_evidence(mut self, evidence: Evidence) -> Self {
807        self.evidence = Some(evidence);
808        self
809    }
810    /// A table value's column types as the source declares them, if
811    /// reported.
812    pub fn column_types(&self) -> Option<&PropertyColumnTypes> {
813        self.column_types.as_ref()
814    }
815    /// The value's type as the source declares it, if reported.
816    pub fn data_type(&self) -> Option<&str> {
817        self.data_type.as_deref()
818    }
819    /// Returns the typed property value.
820    pub fn value(&self) -> &PropertyValue {
821        &self.value
822    }
823}
824
825/// A source-neutral object and its semantic facts.
826#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
827#[serde(deny_unknown_fields)]
828pub struct Object {
829    pub id: ObjectId,
830    pub kind: String,
831    /// Aliases in external schemes, at most one per scheme, sorted by scheme.
832    #[serde(default, skip_serializing_if = "Vec::is_empty")]
833    pub external_ids: Vec<ExternalId>,
834    pub properties: Vec<Property>,
835    pub classifications: Vec<Classification>,
836    pub relationships: BTreeMap<String, Vec<ObjectId>>,
837}
838impl Object {
839    /// Creates an object.
840    pub fn new(id: ObjectId, kind: impl Into<String>) -> Self {
841        Self {
842            id,
843            kind: kind.into(),
844            external_ids: vec![],
845            properties: vec![],
846            classifications: vec![],
847            relationships: BTreeMap::new(),
848        }
849    }
850    /// Adds an external identity, keeping `external_ids` sorted by scheme.
851    ///
852    /// A second identity in the same scheme is kept and rejected when the
853    /// object enters a [`Project`], so the conflict cannot pass unnoticed.
854    pub fn with_external_id(mut self, id: ExternalId) -> Self {
855        let at = self.external_ids.partition_point(|held| held <= &id);
856        self.external_ids.insert(at, id);
857        self
858    }
859    /// The object's identity in `scheme`, if the source states one.
860    pub fn external_id(&self, scheme: &str) -> Option<&str> {
861        self.external_ids
862            .iter()
863            .find(|id| id.scheme == scheme)
864            .map(|id| id.value.as_str())
865    }
866    /// Adds a property.
867    pub fn with_property(mut self, property: Property) -> Self {
868        self.properties.push(property);
869        self
870    }
871    /// Adds a classification.
872    pub fn with_classification(mut self, classification: Classification) -> Self {
873        self.classifications.push(classification);
874        self
875    }
876    /// Object semantic kind.
877    pub fn kind(&self) -> &str {
878        &self.kind
879    }
880    /// Finds a property by namespace and name.
881    pub fn property(&self, set: &str, name: &str) -> Option<&Property> {
882        self.properties
883            .iter()
884            .find(|p| p.property_set == set && p.name == name)
885    }
886}
887
888/// Deterministically indexed source-neutral project graph.
889#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
890#[serde(deny_unknown_fields)]
891pub struct Project {
892    objects: BTreeMap<ObjectId, Object>,
893}
894impl Project {
895    /// Builds a project, rejecting ambiguous IDs.
896    ///
897    /// Ambiguity covers external ids too: an object with two ids in one
898    /// scheme, or two objects of one source sharing an external id, would
899    /// make any consumer that resolves the alias pick one silently.
900    pub fn new(objects: Vec<Object>) -> Result<Self, IrError> {
901        let mut result = Self::default();
902        let mut claimed: BTreeMap<(&SourceId, &ExternalId), &ObjectId> = BTreeMap::new();
903        for object in &objects {
904            // Not a sorted-neighbour check: deserialized objects need not be sorted.
905            let mut schemes = std::collections::BTreeSet::new();
906            if let Some(id) = object
907                .external_ids
908                .iter()
909                .find(|id| !schemes.insert(id.scheme.as_str()))
910            {
911                return Err(IrError::ConflictingExternalId {
912                    object: object.id.clone(),
913                    scheme: id.scheme.clone(),
914                });
915            }
916            for id in &object.external_ids {
917                if let Some(first) = claimed.insert((&object.id.source, id), &object.id) {
918                    let (first, second) = if first <= &object.id {
919                        (first, &object.id)
920                    } else {
921                        (&object.id, first)
922                    };
923                    return Err(IrError::DuplicateExternalId(Box::new(ExternalIdClash {
924                        id: id.clone(),
925                        first: first.clone(),
926                        second: second.clone(),
927                    })));
928                }
929            }
930        }
931        for object in objects {
932            if result
933                .objects
934                .insert(object.id.clone(), object.clone())
935                .is_some()
936            {
937                return Err(IrError::DuplicateObject(object.id));
938            }
939        }
940        Ok(result)
941    }
942    /// Finds an object by source-qualified ID.
943    pub fn object(&self, id: &ObjectId) -> Option<&Object> {
944        self.objects.get(id)
945    }
946    /// Iterates objects in stable identity order.
947    pub fn objects(&self) -> impl Iterator<Item = &Object> {
948        self.objects.values()
949    }
950}
951
952/// A source-neutral object selector.
953#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
954#[serde(deny_unknown_fields)]
955pub struct Selector {
956    pub kinds: Vec<String>,
957    pub classification: Option<Classification>,
958}
959impl Selector {
960    /// Selects a semantic kind.
961    pub fn by_kind(kind: impl Into<String>) -> Self {
962        Self {
963            kinds: vec![kind.into()],
964            classification: None,
965        }
966    }
967    /// Requires a classification.
968    pub fn with_classification(
969        mut self,
970        system: impl Into<String>,
971        code: impl Into<String>,
972    ) -> Self {
973        self.classification = Classification::new(system, code).ok();
974        self
975    }
976    /// Whether an object matches all selector terms.
977    pub fn matches(&self, object: &Object) -> bool {
978        (self.kinds.is_empty() || self.kinds.iter().any(|k| k == &object.kind))
979            && self
980                .classification
981                .as_ref()
982                .is_none_or(|c| object.classifications.contains(c))
983    }
984}
985
986/// Stable package-local rule identity.
987#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
988#[serde(transparent)]
989pub struct RuleId(String);
990impl RuleId {
991    /// Creates an ID.
992    pub fn new(value: impl Into<String>) -> Result<Self, IrError> {
993        Ok(Self(required(value, "rule id")?))
994    }
995}
996impl fmt::Display for RuleId {
997    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
998        self.0.fmt(f)
999    }
1000}
1001
1002/// Severity of a validation finding.
1003#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1004#[serde(rename_all = "snake_case")]
1005pub enum Severity {
1006    Error,
1007    Warning,
1008    Info,
1009}
1010/// What a finding or not-evaluated outcome is about.
1011///
1012/// Most outcomes are about one object. Some are about a whole source ("this
1013/// model has no building") or the whole project ("no storey anywhere has a
1014/// fire compartment"): there is no object to report them against, and
1015/// reporting nothing would read as a pass.
1016///
1017/// Ordered project first, then sources, then objects, each by identity, so
1018/// report ordering stays deterministic.
1019#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
1020pub enum Scope {
1021    /// The whole project, every source together.
1022    Project,
1023    /// One source as a whole.
1024    Source(SourceId),
1025    /// One object.
1026    Object(ObjectId),
1027}
1028
1029impl Scope {
1030    /// The object this scope names, if it names one.
1031    #[must_use]
1032    pub fn object(&self) -> Option<&ObjectId> {
1033        match self {
1034            Self::Object(object) => Some(object),
1035            Self::Project | Self::Source(_) => None,
1036        }
1037    }
1038    /// The source this scope lies in: the named source, or the object's own
1039    /// source. `None` for the project.
1040    #[must_use]
1041    pub fn source(&self) -> Option<&SourceId> {
1042        match self {
1043            Self::Project => None,
1044            Self::Source(source) => Some(source),
1045            Self::Object(object) => Some(&object.source),
1046        }
1047    }
1048    /// Splits the scope into its wire fields, `object_id` and `source`; at
1049    /// most one is set.
1050    fn into_wire(self) -> (Option<ObjectId>, Option<SourceId>) {
1051        match self {
1052            Self::Project => (None, None),
1053            Self::Source(source) => (None, Some(source)),
1054            Self::Object(object) => (Some(object), None),
1055        }
1056    }
1057    fn from_wire(object_id: Option<ObjectId>, source: Option<SourceId>) -> Result<Self, String> {
1058        match (object_id, source) {
1059            (None, None) => Ok(Self::Project),
1060            (None, Some(source)) => Ok(Self::Source(source)),
1061            (Some(object), None) => Ok(Self::Object(object)),
1062            (Some(object), Some(source)) => Err(format!(
1063                "outcome names both object {object} and source {source}; an object already names its source"
1064            )),
1065        }
1066    }
1067}
1068
1069impl From<ObjectId> for Scope {
1070    fn from(object: ObjectId) -> Self {
1071        Self::Object(object)
1072    }
1073}
1074
1075impl From<SourceId> for Scope {
1076    fn from(source: SourceId) -> Self {
1077        Self::Source(source)
1078    }
1079}
1080
1081impl fmt::Display for Scope {
1082    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1083        match self {
1084            Self::Project => f.write_str("project"),
1085            Self::Source(source) => write!(f, "source {source}"),
1086            Self::Object(object) => object.fmt(f),
1087        }
1088    }
1089}
1090
1091/// One storey or space an outcome is located in.
1092#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
1093#[serde(deny_unknown_fields)]
1094pub struct Place {
1095    pub id: ObjectId,
1096    /// The name the source gives it, when it states one.
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub name: Option<String>,
1099}
1100
1101/// Where a finding or not-evaluated outcome is: the storeys and spaces its
1102/// objects lie in, as the host's location method derived them.
1103///
1104/// Empty lists are a located outcome in no storey or space. `unresolved`
1105/// says why part of the location could not be derived: the lists may then
1106/// be incomplete, so a reader filtering by location keeps the outcome
1107/// rather than dropping what might be there.
1108#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
1109#[serde(deny_unknown_fields)]
1110pub struct Location {
1111    /// Sorted by identity, distinct.
1112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1113    pub storeys: Vec<Place>,
1114    /// Sorted by identity, distinct.
1115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1116    pub spaces: Vec<Place>,
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub unresolved: Option<String>,
1119}
1120
1121impl Location {
1122    /// Every storey and space, storeys first.
1123    pub fn places(&self) -> impl Iterator<Item = &Place> {
1124        self.storeys.iter().chain(&self.spaces)
1125    }
1126}
1127
1128/// A deterministic, source-qualified validation outcome.
1129///
1130/// On the wire an object finding carries `object_id`, a source finding
1131/// `source`, and a project finding neither; a record with both is rejected.
1132/// An object finding therefore serializes exactly as before scopes existed.
1133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1134#[serde(try_from = "FindingWire", into = "FindingWire")]
1135pub struct Finding {
1136    /// The finding's stable identity, when the host derived it
1137    /// ([`Report::identify_findings`]). `None` (and absent on the wire)
1138    /// otherwise.
1139    pub id: Option<FindingId>,
1140    pub rule_id: RuleId,
1141    /// What the finding is reported against: an object, a source, or the
1142    /// project. Evidence rules do not depend on it: every finding carries
1143    /// the exact source evidence that decided it.
1144    pub scope: Scope,
1145    pub severity: Severity,
1146    pub message: String,
1147    /// Other objects that participate in the finding -- the slab a wall rests
1148    /// on, the body a space intersects, the objects a count found.
1149    ///
1150    /// A finding a reviewer cannot act on is a finding that gets ignored:
1151    /// "this wall has insufficient contact" is only useful alongside *what*
1152    /// it fails to rest on. Empty when the subject alone explains the finding.
1153    pub related: Vec<ObjectId>,
1154    pub evidence: Vec<Evidence>,
1155    /// The storeys and spaces the finding lies in, when the host located
1156    /// it. `None` (and absent on the wire) unless it asked.
1157    pub location: Option<Location>,
1158    /// The finding's nested categories, outermost first, as the rule's
1159    /// `categories` read them on its subject: each level's values joined by
1160    /// `, `, `-` when none. The same levels head the message in brackets.
1161    /// Empty (and absent on the wire) when the rule declares none.
1162    pub categories: Vec<String>,
1163    /// The reviewer's decision carried over to this finding
1164    /// ([`Report::apply_decisions`]). `None` (and absent on the wire) when
1165    /// undecided or when no decisions were applied.
1166    pub decision: Option<FindingDecision>,
1167}
1168
1169impl Finding {
1170    /// A finding with no related objects and no evidence yet.
1171    #[must_use]
1172    pub fn new(
1173        rule_id: RuleId,
1174        scope: impl Into<Scope>,
1175        severity: Severity,
1176        message: impl Into<String>,
1177    ) -> Self {
1178        Self {
1179            id: None,
1180            rule_id,
1181            scope: scope.into(),
1182            severity,
1183            message: message.into(),
1184            related: Vec::new(),
1185            evidence: Vec::new(),
1186            location: None,
1187            categories: Vec::new(),
1188            decision: None,
1189        }
1190    }
1191    /// The object the finding is reported against, if it is about one.
1192    #[must_use]
1193    pub fn object_id(&self) -> Option<&ObjectId> {
1194        self.scope.object()
1195    }
1196    /// Attaches evidence, sorted by source and locator and deduplicated so
1197    /// ordering never depends on evaluation order.
1198    #[must_use]
1199    pub fn with_evidence(mut self, evidence: impl IntoIterator<Item = Evidence>) -> Self {
1200        self.evidence = evidence.into_iter().collect();
1201        self.evidence
1202            .sort_by(|a, b| (&a.source, &a.locator).cmp(&(&b.source, &b.locator)));
1203        self.evidence.dedup();
1204        self
1205    }
1206    /// Attaches the other objects that participate in this finding, sorted and
1207    /// deduplicated so ordering never depends on adapter traversal order.
1208    #[must_use]
1209    pub fn with_related(mut self, related: impl IntoIterator<Item = ObjectId>) -> Self {
1210        self.related = related.into_iter().collect();
1211        self.related.sort();
1212        self.related.dedup();
1213        // The subject is already named by the scope; repeating it adds noise.
1214        if let Scope::Object(subject) = &self.scope {
1215            let subject = subject.clone();
1216            self.related.retain(|candidate| candidate != &subject);
1217        }
1218        self
1219    }
1220}
1221
1222/// The serialized form of a [`Finding`], compatible with reports written
1223/// before scopes existed.
1224#[derive(Serialize, Deserialize)]
1225#[serde(deny_unknown_fields)]
1226struct FindingWire {
1227    #[serde(default, skip_serializing_if = "Option::is_none")]
1228    id: Option<FindingId>,
1229    rule_id: RuleId,
1230    #[serde(default, skip_serializing_if = "Option::is_none")]
1231    object_id: Option<ObjectId>,
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    source: Option<SourceId>,
1234    severity: Severity,
1235    message: String,
1236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1237    related: Vec<ObjectId>,
1238    evidence: Vec<Evidence>,
1239    #[serde(default, skip_serializing_if = "Option::is_none")]
1240    location: Option<Location>,
1241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1242    categories: Vec<String>,
1243    #[serde(default, skip_serializing_if = "Option::is_none")]
1244    decision: Option<FindingDecision>,
1245}
1246
1247impl From<Finding> for FindingWire {
1248    fn from(finding: Finding) -> Self {
1249        let (object_id, source) = finding.scope.into_wire();
1250        Self {
1251            id: finding.id,
1252            rule_id: finding.rule_id,
1253            object_id,
1254            source,
1255            severity: finding.severity,
1256            message: finding.message,
1257            related: finding.related,
1258            evidence: finding.evidence,
1259            location: finding.location,
1260            categories: finding.categories,
1261            decision: finding.decision,
1262        }
1263    }
1264}
1265
1266impl TryFrom<FindingWire> for Finding {
1267    type Error = String;
1268    fn try_from(wire: FindingWire) -> Result<Self, String> {
1269        Ok(Self {
1270            id: wire.id,
1271            rule_id: wire.rule_id,
1272            scope: Scope::from_wire(wire.object_id, wire.source)?,
1273            severity: wire.severity,
1274            message: wire.message,
1275            related: wire.related,
1276            evidence: wire.evidence,
1277            location: wire.location,
1278            categories: wire.categories,
1279            decision: wire.decision,
1280        })
1281    }
1282}
1283/// Why an object or rule instance could not be evaluated conclusively.
1284#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1285#[serde(rename_all = "snake_case")]
1286pub enum NotEvaluatedReason {
1287    MissingService,
1288    BackendUnavailable,
1289    IncompleteEvidence,
1290    InvalidEvidence,
1291    InvalidDeclaration,
1292    ResourceLimit,
1293    /// The package names a concept the source's declared vocabulary cannot
1294    /// express. A fact about the package and the source, never about one
1295    /// object, so the runtime reports it once per rule and source.
1296    UnboundConcept,
1297    /// The source records the consulted kind of fact for no object at all
1298    /// (a model with no presentation layers), so neither a value nor an
1299    /// absence can be stated and the rule does not apply to that source. A
1300    /// fact about the source, never about one object, so the runtime reports
1301    /// it once per rule and source.
1302    NotRecorded,
1303}
1304/// Explicit fail-closed evaluation outcome. This is not a compliance finding.
1305///
1306/// On the wire `object_id` is always written (`null` unless the outcome is
1307/// about one object) and `source` only for a source-scoped outcome, so object-
1308/// and rule-level outcomes serialize exactly as before scopes existed.
1309#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1310#[serde(try_from = "NotEvaluatedWire", into = "NotEvaluatedWire")]
1311pub struct NotEvaluated {
1312    pub rule_id: RuleId,
1313    /// What could not be evaluated: one object, one source, or the rule as a
1314    /// whole (`Scope::Project`).
1315    pub scope: Scope,
1316    pub reason: NotEvaluatedReason,
1317    pub message: String,
1318    /// The storeys and spaces the outcome's object lies in, when the host
1319    /// located it. `None` (and absent on the wire) unless it asked.
1320    pub location: Option<Location>,
1321}
1322
1323impl NotEvaluated {
1324    /// The object that could not be evaluated, if the outcome is about one.
1325    #[must_use]
1326    pub fn object_id(&self) -> Option<&ObjectId> {
1327        self.scope.object()
1328    }
1329}
1330
1331#[derive(Serialize, Deserialize)]
1332#[serde(deny_unknown_fields)]
1333struct NotEvaluatedWire {
1334    rule_id: RuleId,
1335    #[serde(default)]
1336    object_id: Option<ObjectId>,
1337    #[serde(default, skip_serializing_if = "Option::is_none")]
1338    source: Option<SourceId>,
1339    reason: NotEvaluatedReason,
1340    message: String,
1341    #[serde(default, skip_serializing_if = "Option::is_none")]
1342    location: Option<Location>,
1343}
1344
1345impl From<NotEvaluated> for NotEvaluatedWire {
1346    fn from(outcome: NotEvaluated) -> Self {
1347        let (object_id, source) = outcome.scope.into_wire();
1348        Self {
1349            rule_id: outcome.rule_id,
1350            object_id,
1351            source,
1352            reason: outcome.reason,
1353            message: outcome.message,
1354            location: outcome.location,
1355        }
1356    }
1357}
1358
1359impl TryFrom<NotEvaluatedWire> for NotEvaluated {
1360    type Error = String;
1361    fn try_from(wire: NotEvaluatedWire) -> Result<Self, String> {
1362        Ok(Self {
1363            rule_id: wire.rule_id,
1364            scope: Scope::from_wire(wire.object_id, wire.source)?,
1365            reason: wire.reason,
1366            message: wire.message,
1367            location: wire.location,
1368        })
1369    }
1370}
1371/// How one rule fared overall.
1372#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
1373#[serde(rename_all = "snake_case")]
1374pub enum RuleStatus {
1375    /// Something was checked and nothing found or left open.
1376    Passed,
1377    /// At least one finding.
1378    Failed,
1379    /// No finding, but something was not evaluated. Never a pass.
1380    NotEvaluated,
1381    /// The rule surely selected nothing and reported nothing: it passed
1382    /// vacuously, which a reader must be able to tell from a pass.
1383    NothingSelected,
1384    /// The rule's gate on another rule's outcome was closed, so it did not
1385    /// run; it reports nothing and checked nothing.
1386    Skipped,
1387}
1388
1389/// One rule's counts over the objects it checked.
1390///
1391/// `checked` is the size of the rule's decided selection: the objects its
1392/// applicability selector surely selects. `failed` and `not_evaluated`
1393/// count the distinct objects its findings and not-evaluated outcomes are
1394/// about; an outcome about a source or the project counts no object, but
1395/// still decides the status.
1396#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1397#[serde(deny_unknown_fields)]
1398pub struct RuleSummary {
1399    pub rule_id: RuleId,
1400    pub checked: usize,
1401    pub failed: usize,
1402    pub not_evaluated: usize,
1403    pub status: RuleStatus,
1404}
1405
1406impl RuleSummary {
1407    /// The summary of a rule its gate skipped.
1408    #[must_use]
1409    pub fn skipped(rule_id: RuleId) -> Self {
1410        Self {
1411            rule_id,
1412            checked: 0,
1413            failed: 0,
1414            not_evaluated: 0,
1415            status: RuleStatus::Skipped,
1416        }
1417    }
1418    /// The summary of a rule with these counts, and whether it found
1419    /// anything or left anything not evaluated at any scope.
1420    #[must_use]
1421    pub fn new(
1422        rule_id: RuleId,
1423        checked: usize,
1424        (failed, found): (usize, bool),
1425        (not_evaluated, open): (usize, bool),
1426    ) -> Self {
1427        let status = if found {
1428            RuleStatus::Failed
1429        } else if open {
1430            RuleStatus::NotEvaluated
1431        } else if checked == 0 {
1432            RuleStatus::NothingSelected
1433        } else {
1434            RuleStatus::Passed
1435        };
1436        Self {
1437            rule_id,
1438            checked,
1439            failed,
1440            not_evaluated,
1441            status,
1442        }
1443    }
1444}
1445
1446/// Ordered report from a plan execution.
1447///
1448/// `tables` holds the measured values rules report beside their findings,
1449/// ordered by rule and table name. It is omitted from the serialized form
1450/// when empty, so a report without tables serializes byte for byte as
1451/// before tables existed. So is `stale_decisions`, the decisions
1452/// [`Report::apply_decisions`] found no finding for.
1453#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1454#[serde(deny_unknown_fields)]
1455pub struct Report {
1456    pub findings: Vec<Finding>,
1457    #[serde(default)]
1458    pub not_evaluated: Vec<NotEvaluated>,
1459    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1460    pub tables: Vec<ReportTable>,
1461    /// One summary per rule, by rule id, when the host asked for them;
1462    /// omitted when empty, so a report without them serializes as before.
1463    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1464    pub rules: Vec<RuleSummary>,
1465    /// Decisions naming no finding of this report, by finding identity.
1466    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1467    pub stale_decisions: Vec<Decision>,
1468    /// The resource objects the report's outcomes name, sorted by identity.
1469    ///
1470    /// A resource object is a source instance outside the project's object
1471    /// population (an IFC material, a classification, a relationship) that a
1472    /// rule selected by naming its class. The project cannot resolve its
1473    /// identity, so the report carries it: consumers resolve an outcome's
1474    /// object through [`Report::object`]. Omitted when empty, so a report
1475    /// naming no resource serializes as before resources existed.
1476    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1477    pub resources: Vec<Object>,
1478}
1479impl Report {
1480    /// Findings in deterministic order.
1481    pub fn findings(&self) -> &[Finding] {
1482        &self.findings
1483    }
1484    /// Fail-closed rule or object evaluations in deterministic order.
1485    pub fn not_evaluated(&self) -> &[NotEvaluated] {
1486        &self.not_evaluated
1487    }
1488    /// Tables of measured values, by rule and table name.
1489    pub fn tables(&self) -> &[ReportTable] {
1490        &self.tables
1491    }
1492    /// Per-rule counts and status, by rule id; empty unless the host asked.
1493    pub fn rules(&self) -> &[RuleSummary] {
1494        &self.rules
1495    }
1496    /// Decisions [`Report::apply_decisions`] found no finding for.
1497    pub fn stale_decisions(&self) -> &[Decision] {
1498        &self.stale_decisions
1499    }
1500    /// Sets every finding's [`Finding::id`], keyed by the object aliases in
1501    /// `stable_scheme` (see [`identity`]).
1502    ///
1503    /// # Errors
1504    ///
1505    /// [`IdentityError::UnknownObject`] when a finding names an object
1506    /// `project` does not contain. Nothing is changed then.
1507    pub fn identify_findings(
1508        &mut self,
1509        project: &Project,
1510        stable_scheme: &str,
1511    ) -> Result<(), IdentityError> {
1512        let ids = finding_ids(self, project, stable_scheme)?;
1513        for (finding, id) in self.findings.iter_mut().zip(ids) {
1514            finding.id = Some(id);
1515        }
1516        Ok(())
1517    }
1518    /// The resource object `id` the report names, if it names one.
1519    pub fn resource(&self, id: &ObjectId) -> Option<&Object> {
1520        self.resources
1521            .binary_search_by(|resource| resource.id.cmp(id))
1522            .ok()
1523            .map(|index| &self.resources[index])
1524    }
1525    /// The object `id` of `project`, or else the resource object `id` the
1526    /// report names.
1527    pub fn object<'a>(&'a self, project: &'a Project, id: &ObjectId) -> Option<&'a Object> {
1528        project.object(id).or_else(|| self.resource(id))
1529    }
1530    /// The table `name` of `rule_id`, if the report has it.
1531    pub fn table(&self, rule_id: &RuleId, name: &str) -> Option<&ReportTable> {
1532        self.tables
1533            .iter()
1534            .find(|table| table.rule_id() == rule_id && table.name() == name)
1535    }
1536}