openbim_ids/version.rs
1//! Version detection that reports the evidence it used.
2//!
3//! # The hazard
4//!
5//! Every published IDS revision declares the same `targetNamespace`, so the
6//! namespace cannot discriminate. The revisions differ in attribute *names*
7//! and in where occurrence lives, not in element names — so a reader that
8//! guesses wrong does not fail. It produces a *different specification*,
9//! silently, and an audit run against it reports confident wrong answers.
10//!
11//! This module therefore never guesses. It collects the shape signals a
12//! document exhibits, infers a revision from them, and compares that against
13//! whatever the document declared. Disagreement becomes
14//! [`Detected::Conflict`], which [`Detected::resolved`] refuses to collapse.
15//!
16//! # Scope
17//!
18//! [`VersionSignals`] is the evidence *model*, deliberately independent of any
19//! XML reader: it takes observations and decides. Populating it from a document
20//! belongs to the parser (`IDS-PARSE`), which keeps the detection rules
21//! testable now and keeps this crate free of a parser dependency it does not
22//! yet need.
23
24use crate::IdsVersion;
25use openbim_core::Detected;
26
27/// A shape signal observed in a document, and the revision it indicates.
28///
29/// Each variant is an *observation*, not a conclusion — a document can exhibit
30/// signals pointing at different revisions, which is precisely the case that
31/// must be reported rather than resolved.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub enum Signal {
34 /// A requirement facet carries `minOccurs` / `maxOccurs`.
35 ///
36 /// Pre-1.0: 1.0 moved occurrence onto `<applicability>` and gave facets
37 /// `@cardinality` instead. This is the single most reliable tell.
38 OccursOnRequirementFacet,
39 /// A requirement facet carries `@cardinality`.
40 ///
41 /// 1.0 only — the attribute does not exist before it.
42 CardinalityOnRequirementFacet,
43 /// A property facet uses `<name>`.
44 ///
45 /// Pre-1.0: renamed to `<baseName>` in 1.0.
46 PropertyName,
47 /// A property facet uses `<baseName>`.
48 ///
49 /// 1.0 only.
50 PropertyBaseName,
51 /// A property facet carries `@measure`.
52 ///
53 /// Pre-1.0: renamed to `@dataType` in 1.0.
54 MeasureAttribute,
55 /// A property facet carries `@dataType`.
56 ///
57 /// 1.0 only.
58 DataTypeAttribute,
59 /// An `ifcVersion` lists `IFC4X3`.
60 ///
61 /// Pre-1.0: 1.0 spells it `IFC4X3_ADD2`.
62 Ifc4x3Unsuffixed,
63 /// An `ifcVersion` lists `IFC4X3_ADD2`.
64 ///
65 /// 1.0 only.
66 Ifc4x3Add2,
67 /// A classification facet omits `<system>`.
68 ///
69 /// Pre-1.0: `<system>` became required in 1.0. Weaker than the others —
70 /// its *presence* proves nothing, since 0.9 permits it too.
71 ClassificationWithoutSystem,
72}
73
74impl Signal {
75 /// The revision this signal indicates, or `None` if it only excludes 1.0.
76 ///
77 /// Pre-1.0 signals cannot pick between 0.9, 0.9.6 and 0.9.7 — those
78 /// revisions are not distinguished by the shapes listed here, so claiming
79 /// a specific draft would be an invention. They resolve to
80 /// [`IdsVersion::Draft0_9`] as the least specific pre-1.0 revision.
81 #[must_use]
82 pub fn indicates(self) -> IdsVersion {
83 match self {
84 Signal::CardinalityOnRequirementFacet
85 | Signal::PropertyBaseName
86 | Signal::DataTypeAttribute
87 | Signal::Ifc4x3Add2 => IdsVersion::Ids1_0,
88 Signal::OccursOnRequirementFacet
89 | Signal::PropertyName
90 | Signal::MeasureAttribute
91 | Signal::Ifc4x3Unsuffixed
92 | Signal::ClassificationWithoutSystem => IdsVersion::Draft0_9,
93 }
94 }
95
96 /// Whether this signal is exclusive to IDS 1.0.
97 #[must_use]
98 pub fn is_1_0_only(self) -> bool {
99 self.indicates() == IdsVersion::Ids1_0
100 }
101}
102
103/// The shape evidence a document exhibits.
104///
105/// Built by the reader as it walks a document, then interrogated once at the
106/// end — a single facet is not enough to judge a file, and a mixed document
107/// must be reported as mixed rather than decided by whichever facet was seen
108/// last.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct VersionSignals {
111 observed: std::collections::BTreeSet<Signal>,
112}
113
114impl VersionSignals {
115 /// Evidence with nothing observed yet.
116 #[must_use]
117 pub fn new() -> Self {
118 Self::default()
119 }
120
121 /// Records a signal. Repeat observations are idempotent.
122 pub fn observe(&mut self, signal: Signal) -> &mut Self {
123 self.observed.insert(signal);
124 self
125 }
126
127 /// Whether a given signal was observed.
128 #[must_use]
129 pub fn saw(&self, signal: Signal) -> bool {
130 self.observed.contains(&signal)
131 }
132
133 /// Whether any signal was observed at all.
134 #[must_use]
135 pub fn is_empty(&self) -> bool {
136 self.observed.is_empty()
137 }
138
139 /// Every observed signal, in a stable order.
140 pub fn iter(&self) -> impl Iterator<Item = Signal> + '_ {
141 self.observed.iter().copied()
142 }
143
144 /// The observed signals that are exclusive to 1.0.
145 #[must_use]
146 pub fn signals_for_1_0(&self) -> Vec<Signal> {
147 self.iter().filter(|s| s.is_1_0_only()).collect()
148 }
149
150 /// The observed signals that indicate a pre-1.0 draft.
151 #[must_use]
152 pub fn signals_for_draft(&self) -> Vec<Signal> {
153 self.iter().filter(|s| !s.is_1_0_only()).collect()
154 }
155
156 /// Whether the document mixes shapes from incompatible revisions.
157 ///
158 /// A document doing this is malformed under *both* revisions. It is
159 /// reported, never repaired: choosing which half to believe changes which
160 /// requirements exist.
161 #[must_use]
162 pub fn is_internally_inconsistent(&self) -> bool {
163 !self.signals_for_1_0().is_empty() && !self.signals_for_draft().is_empty()
164 }
165
166 /// The revision the shape indicates, if the evidence is unambiguous.
167 ///
168 /// Returns `None` when nothing was observed, and also when the evidence is
169 /// self-contradictory — both are cases where a caller must not receive a
170 /// confident answer.
171 #[must_use]
172 pub fn infer(&self) -> Option<IdsVersion> {
173 if self.is_empty() || self.is_internally_inconsistent() {
174 return None;
175 }
176 Some(if self.signals_for_1_0().is_empty() {
177 IdsVersion::Draft0_9
178 } else {
179 IdsVersion::Ids1_0
180 })
181 }
182}
183
184/// Resolves a document's version from what it declared and what it looks like.
185///
186/// The four outcomes:
187///
188/// - declared, and the shape agrees (or is silent) → [`Detected::Declared`]
189/// - nothing declared, shape unambiguous → [`Detected::Inferred`]
190/// - declared, shape says otherwise → [`Detected::Conflict`]
191/// - nothing declared and no usable shape evidence → `None`
192///
193/// The conflict case is the one that matters: a document declaring 1.0 while
194/// carrying `minOccurs` on a requirement facet is a pre-1.0 draft with a
195/// rewritten header, and reading it as 1.0 loses every occurrence constraint
196/// without erroring.
197///
198/// ```
199/// use openbim_ids::{detect_version, IdsVersion, Signal, VersionSignals};
200/// use openbim_core::Detected;
201///
202/// let mut signals = VersionSignals::new();
203/// signals.observe(Signal::OccursOnRequirementFacet);
204///
205/// // A 1.0 claim over a pre-1.0 shape must not resolve.
206/// let detected = detect_version(Some(IdsVersion::Ids1_0), &signals).expect("evidence");
207/// assert!(detected.is_conflict());
208/// assert_eq!(detected.resolved(), None);
209/// ```
210#[must_use]
211pub fn detect_version(
212 declared: Option<IdsVersion>,
213 signals: &VersionSignals,
214) -> Option<Detected<IdsVersion>> {
215 let observed = signals.infer();
216 match (declared, observed) {
217 // Both present: agreement confirms the claim, disagreement is reported.
218 (Some(declared), Some(observed)) => Some(if versions_agree(declared, observed) {
219 Detected::Declared(declared)
220 } else {
221 Detected::Conflict { declared, observed }
222 }),
223 // A claim with no usable shape evidence is taken at face value; there
224 // is nothing to contradict it. An internally inconsistent document
225 // yields no inference, so it lands here and keeps the declared value.
226 (Some(declared), None) => Some(Detected::Declared(declared)),
227 // No claim, but the shape is unambiguous.
228 (None, Some(observed)) => Some(Detected::Inferred(observed)),
229 (None, None) => None,
230 }
231}
232
233/// Whether a declared revision is consistent with an inferred one.
234///
235/// Shape inference cannot separate the pre-1.0 drafts from each other, so any
236/// declared draft is consistent with an inferred `Draft0_9`. Treating a
237/// declared 0.9.7 as conflicting with an inferred "some draft" would be a false
238/// positive, and false conflicts train callers to ignore real ones.
239fn versions_agree(declared: IdsVersion, observed: IdsVersion) -> bool {
240 match observed {
241 IdsVersion::Ids1_0 => declared == IdsVersion::Ids1_0,
242 _ => declared != IdsVersion::Ids1_0,
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn signals(list: &[Signal]) -> VersionSignals {
251 let mut s = VersionSignals::new();
252 for signal in list {
253 s.observe(*signal);
254 }
255 s
256 }
257
258 #[test]
259 fn every_signal_indicates_a_revision() {
260 let all = [
261 Signal::OccursOnRequirementFacet,
262 Signal::CardinalityOnRequirementFacet,
263 Signal::PropertyName,
264 Signal::PropertyBaseName,
265 Signal::MeasureAttribute,
266 Signal::DataTypeAttribute,
267 Signal::Ifc4x3Unsuffixed,
268 Signal::Ifc4x3Add2,
269 Signal::ClassificationWithoutSystem,
270 ];
271 for signal in all {
272 // Exhaustive match: a new variant without an `indicates` arm fails
273 // to compile, so it cannot silently default to a revision.
274 assert!(matches!(
275 signal.indicates(),
276 IdsVersion::Ids1_0 | IdsVersion::Draft0_9
277 ));
278 }
279 assert_eq!(all.iter().filter(|s| s.is_1_0_only()).count(), 4);
280 }
281
282 /// The headline case from the issue.
283 #[test]
284 fn occurs_on_a_requirement_facet_is_a_pre_1_0_tell() {
285 let s = signals(&[Signal::OccursOnRequirementFacet]);
286 assert_eq!(s.infer(), Some(IdsVersion::Draft0_9));
287 }
288
289 #[test]
290 fn cardinality_on_a_requirement_facet_is_1_0() {
291 let s = signals(&[Signal::CardinalityOnRequirementFacet]);
292 assert_eq!(s.infer(), Some(IdsVersion::Ids1_0));
293 }
294
295 /// A 1.0 document carrying `minOccurs` must be reported, not parsed.
296 #[test]
297 fn declared_1_0_over_a_draft_shape_is_a_conflict() {
298 let s = signals(&[Signal::OccursOnRequirementFacet]);
299 let detected = detect_version(Some(IdsVersion::Ids1_0), &s).expect("evidence");
300 assert_eq!(
301 detected,
302 Detected::Conflict {
303 declared: IdsVersion::Ids1_0,
304 observed: IdsVersion::Draft0_9,
305 }
306 );
307 assert!(detected.is_conflict());
308 assert_eq!(detected.resolved(), None, "a conflict must not resolve");
309 }
310
311 #[test]
312 fn declared_draft_over_a_1_0_shape_is_a_conflict() {
313 let s = signals(&[Signal::CardinalityOnRequirementFacet]);
314 let detected = detect_version(Some(IdsVersion::Draft0_9_7), &s).expect("evidence");
315 assert!(detected.is_conflict());
316 }
317
318 #[test]
319 fn agreement_is_declared_not_inferred() {
320 let s = signals(&[
321 Signal::CardinalityOnRequirementFacet,
322 Signal::PropertyBaseName,
323 ]);
324 assert_eq!(
325 detect_version(Some(IdsVersion::Ids1_0), &s),
326 Some(Detected::Declared(IdsVersion::Ids1_0))
327 );
328 }
329
330 #[test]
331 fn shape_alone_infers() {
332 let s = signals(&[Signal::PropertyBaseName, Signal::DataTypeAttribute]);
333 assert_eq!(
334 detect_version(None, &s),
335 Some(Detected::Inferred(IdsVersion::Ids1_0))
336 );
337 }
338
339 #[test]
340 fn no_declaration_and_no_evidence_yields_nothing() {
341 assert_eq!(detect_version(None, &VersionSignals::new()), None);
342 }
343
344 #[test]
345 fn a_declaration_with_no_evidence_stands() {
346 assert_eq!(
347 detect_version(Some(IdsVersion::Ids1_0), &VersionSignals::new()),
348 Some(Detected::Declared(IdsVersion::Ids1_0))
349 );
350 }
351
352 /// Inference cannot separate the drafts, so declaring one is not a conflict.
353 #[test]
354 fn a_declared_draft_does_not_conflict_with_an_inferred_draft() {
355 let s = signals(&[Signal::PropertyName]);
356 for declared in [
357 IdsVersion::Draft0_9,
358 IdsVersion::Draft0_9_6,
359 IdsVersion::Draft0_9_7,
360 ] {
361 let detected = detect_version(Some(declared), &s).expect("evidence");
362 assert_eq!(
363 detected,
364 Detected::Declared(declared),
365 "{declared} must not be reported as conflicting with a draft shape"
366 );
367 }
368 }
369
370 #[test]
371 fn mixed_shapes_are_inconsistent_and_do_not_infer() {
372 let s = signals(&[
373 Signal::OccursOnRequirementFacet,
374 Signal::CardinalityOnRequirementFacet,
375 ]);
376 assert!(s.is_internally_inconsistent());
377 assert_eq!(
378 s.infer(),
379 None,
380 "a self-contradictory document must not infer"
381 );
382 }
383
384 /// Each rename from the issue, in both directions.
385 #[test]
386 fn each_rename_is_detected() {
387 let cases = [
388 (Signal::PropertyName, Signal::PropertyBaseName),
389 (Signal::MeasureAttribute, Signal::DataTypeAttribute),
390 (Signal::Ifc4x3Unsuffixed, Signal::Ifc4x3Add2),
391 ];
392 for (old, new) in cases {
393 assert_eq!(
394 signals(&[old]).infer(),
395 Some(IdsVersion::Draft0_9),
396 "{old:?} must indicate a draft"
397 );
398 assert_eq!(
399 signals(&[new]).infer(),
400 Some(IdsVersion::Ids1_0),
401 "{new:?} must indicate 1.0"
402 );
403 }
404 }
405
406 /// `<system>` became required in 1.0, so only its absence is evidence.
407 #[test]
408 fn missing_classification_system_indicates_a_draft() {
409 assert_eq!(
410 signals(&[Signal::ClassificationWithoutSystem]).infer(),
411 Some(IdsVersion::Draft0_9)
412 );
413 }
414
415 #[test]
416 fn observing_is_idempotent_and_queryable() {
417 let mut s = VersionSignals::new();
418 assert!(s.is_empty());
419 s.observe(Signal::PropertyBaseName)
420 .observe(Signal::PropertyBaseName);
421 assert!(!s.is_empty());
422 assert!(s.saw(Signal::PropertyBaseName));
423 assert!(!s.saw(Signal::PropertyName));
424 assert_eq!(s.iter().count(), 1);
425 }
426}