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}