diff --git a/CHANGELOG.md b/CHANGELOG.md index fdfe4c8..7b8447e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An element restricting a NON-repeating base choice ({max occurs} = 1) is now + checked by the literal RecurseAsIfGroup pairing (XSD 1.0 section 3.9.6): the + derived element, with its own occurrence range, must be a valid restriction + of some branch. The effective-total-range approximation is kept only for a + repeating base choice, where the particlesZ001 ambiguity actually lives + (occurrence counts accumulated across the choice's own repetitions, which the + literal rule cannot express); at {max occurs} = 1 no such accumulation exists + and the spec text governs. Closes XSTS particlesHa161 (an optional derived + element is not a pointwise restriction of a required branch) and + particlesM034 (a derived `d1+` cannot map into a branch `(d1*, d2+)` whose + required `d2` is unmapped and not emptiable; the settled-valid twin + particlesM035, `d2+` over the same branch, still passes), taking + invalid-schemas-accepted 6 -> 4; stricter than libxml2, which implements + neither edge. Separately, particlesIg004 is reclassified as a spec-divergent + schema test (4 -> 3): section 3.3.2 maps `block="#all"` and the explicit + `substitution extension restriction` list to the same {disallowed + substitutions} set, so NameAndTypeOK clause 5's superset requirement holds + and the `invalid` expectation has no spec path (jointly unsatisfiable with + the settled Ig001/002/003 and Ig005 expectations). The remaining three + schema deviations (Ik025, Ik027, schN12) are documented policy divergences + in `docs/xsts-deviations.md`, including why src-redefine 6.2.2 cannot be + enforced uniformly (mgO013's settled-valid schema violates it). Valid + buckets are unchanged at zero. + - A complex-type restriction that swaps an element reference for one of its substitution-group members is now rejected (NameAndTypeOK, XSD 1.0 section 3.9.6 Elt:Elt clause 1: the declared names must match, and no clause grants diff --git a/Sources/Schema/ParticleRestriction.swift b/Sources/Schema/ParticleRestriction.swift index e0091ba..bb20d96 100644 --- a/Sources/Schema/ParticleRestriction.swift +++ b/Sources/Schema/ParticleRestriction.swift @@ -150,7 +150,7 @@ extension PureXML.Schema { } } - private static func groupValid(_ restricted: Group, _ base: Group, _ types: [String: ElementType], _ derivation: [String: TypeDerivation]) -> Bool { + static func groupValid(_ restricted: Group, _ base: Group, _ types: [String: ElementType], _ derivation: [String: TypeDerivation]) -> Bool { // A particle that can never occur (maxOccurs=0) contributes nothing to the // language, so it is removed before mapping (a "pointless particle"). Left // in, it would wrongly consume a base particle and starve the particles @@ -316,54 +316,6 @@ extension PureXML.Schema { lhs.localName == rhs.localName && (lhs.namespaceURI ?? "") == (rhs.namespaceURI ?? "") } - /// RecurseAsIfGroup against a CHOICE, read by effective total range. The - /// literal §3.9.6 rule under-approximates the ground truth (derived language ⊆ - /// base language) and over-rejects a repeating base choice (particlesZ001, whose - /// test doc calls the rule "ambiguous"; W3C reads it valid). First principles: - /// the element validly restricts the choice iff its leaf is a NameAndTypeOK - /// restriction of some element the choice admits AND its effective occurrence - /// range is within the choice's. Accepts `element{0,∞}` over `choice{0,∞}(...)` - /// (Z001) and `c1{2,2}` over a choice whose branch holds `c1` (M002/003); a - /// must-repeat choice (effective min ≥ 2) still rejects a single element - /// (particlesL). A documented, bounded under-rejection (exact in-branch - /// sequencing is not modelled); over-rejection is the non-starter. - private static func elementRestrictsGroup( - _ restricted: Particle, - _ base: Particle, - _ baseGroup: Group, - _ types: [String: ElementType], - _ derivation: [String: TypeDerivation], - ) -> Bool { - if baseGroup.compositor == .choice { - return elementRestrictsChoice(restricted, base, baseGroup, types, derivation) - } - // RecurseAsIfGroup for a sequence/all base (§3.9.6): the element as a - // {1,1} synthetic group must be within the base group's range and map to - // a base member inside `groupValid`. - return rangeWithinWildcard(derivedMin: 1, derivedMax: 1, wildcardMin: base.minOccurs, wildcardMax: base.maxOccurs) - && groupValid(Group(compositor: .sequence, particles: [restricted]), baseGroup, types, derivation) - } - - private static func elementRestrictsChoice( - _ restricted: Particle, - _ base: Particle, - _ baseGroup: Group, - _ types: [String: ElementType], - _ derivation: [String: TypeDerivation], - ) -> Bool { - let neutral = Particle(minOccurs: 1, maxOccurs: 1, term: restricted.term) - let admitted = elementLeaves(of: baseGroup).contains { - valid(neutral, Particle(minOccurs: 1, maxOccurs: 1, term: $0), types, derivation) - } - return admitted - && rangeWithinWildcard( - derivedMin: restricted.effectiveOccurrenceMin(), - derivedMax: restricted.effectiveOccurrenceMax(), - wildcardMin: base.effectiveOccurrenceMin(), - wildcardMax: base.effectiveOccurrenceMax(), - ) - } - /// Whether every leaf a particle can contain is admitted by `wildcard`: an /// element by name, a nested wildcard by narrowing. private static func leavesAdmitted(_ particle: Particle, by wildcard: Wildcard) -> Bool { @@ -376,7 +328,7 @@ extension PureXML.Schema { /// Whether a derived effective total range fits within a base wildcard's /// occurrence range: at least the wildcard's minimum, at most its maximum. - private static func rangeWithinWildcard(derivedMin: Int, derivedMax: Int?, wildcardMin: Int, wildcardMax: Int?) -> Bool { + static func rangeWithinWildcard(derivedMin: Int, derivedMax: Int?, wildcardMin: Int, wildcardMax: Int?) -> Bool { guard derivedMin >= wildcardMin else { return false } guard let wildcardMax else { return true } guard let derivedMax else { return false } diff --git a/Sources/Schema/ParticleRestrictionChoice.swift b/Sources/Schema/ParticleRestrictionChoice.swift new file mode 100644 index 0000000..a22ede1 --- /dev/null +++ b/Sources/Schema/ParticleRestrictionChoice.swift @@ -0,0 +1,72 @@ +extension PureXML.Schema.ParticleRestriction { + /// RecurseAsIfGroup against a CHOICE, split by whether the choice can repeat. + /// A non-repeating choice ({max occurs} = 1) gets the literal §3.9.6 pairing + /// (no occurrence ambiguity exists, so the spec text governs: particlesHa161, + /// particlesM034). A repeating choice is read by effective total range, because + /// the literal rule has no allowance for counts accumulated across the choice's + /// repetitions and over-rejects W3C-read-valid schemas (particlesZ001); the + /// rationale for both regimes is inline in `elementRestrictsChoice`. + static func elementRestrictsGroup( + _ restricted: PureXML.Schema.Particle, + _ base: PureXML.Schema.Particle, + _ baseGroup: PureXML.Schema.Group, + _ types: [String: PureXML.Schema.ElementType], + _ derivation: [String: PureXML.Schema.TypeDerivation], + ) -> Bool { + if baseGroup.compositor == .choice { + return elementRestrictsChoice(restricted, base, baseGroup, types, derivation) + } + // RecurseAsIfGroup for a sequence/all base (§3.9.6): the element as a + // {1,1} synthetic group must be within the base group's range and map to + // a base member inside `groupValid`. + return rangeWithinWildcard(derivedMin: 1, derivedMax: 1, wildcardMin: base.minOccurs, wildcardMax: base.maxOccurs) + && groupValid(PureXML.Schema.Group(compositor: .sequence, particles: [restricted]), baseGroup, types, derivation) + } + + private static func elementRestrictsChoice( + _ restricted: PureXML.Schema.Particle, + _ base: PureXML.Schema.Particle, + _ baseGroup: PureXML.Schema.Group, + _ types: [String: PureXML.Schema.ElementType], + _ derivation: [String: PureXML.Schema.TypeDerivation], + ) -> Bool { + // A base choice that cannot repeat ({max occurs} = 1) has no occurrence + // ambiguity: every child the type accepts comes from ONE traversal of ONE + // branch, so the literal RecurseAsIfGroup applies as written. The derived + // element (with its own occurrence range) must be a valid restriction of + // some non-pointless branch: for an element branch that is pointwise + // NameAndTypeOK (a derived `a{0,1}` does not restrict a branch `a{1,1}`, + // particlesHa161); for a group branch the element maps into the branch + // with the branch's other members emptiable (a derived `d1+` does not + // restrict `(d1*, d2+)` because every traversal emits a `d2`, + // particlesM034; its twin `d2+` over the same branch is valid, + // particlesM035). The effective-range reading below would accept both. + if base.maxOccurs == 1 { + return rangeWithinWildcard(derivedMin: 1, derivedMax: 1, wildcardMin: base.minOccurs, wildcardMax: base.maxOccurs) + && baseGroup.particles.filter { $0.maxOccurs != 0 }.contains { valid(restricted, $0, types, derivation) } + } + // A REPEATING base choice is the case the spec's literal rule handles + // badly: §3.9.6 pairs the derived element against one branch with no + // allowance for counts accumulated across the choice's own repetitions, + // which over-rejects e.g. `element{0,∞}` restricting `choice{0,∞}(...)` + // (particlesZ001, whose test doc calls the rule "ambiguous"; W3C reads it + // valid). First principles: the element validly restricts the repeating + // choice iff its leaf is a NameAndTypeOK restriction of some element the + // choice admits AND its effective occurrence range is within the choice's. + // A must-repeat choice (effective min ≥ 2) still rejects a single element + // (particlesL). A documented, bounded under-rejection (exact in-branch + // sequencing is not modelled across repetitions); over-rejection of a + // W3C-read-valid schema is the non-starter. + let neutral = PureXML.Schema.Particle(minOccurs: 1, maxOccurs: 1, term: restricted.term) + let admitted = elementLeaves(of: baseGroup).contains { + valid(neutral, PureXML.Schema.Particle(minOccurs: 1, maxOccurs: 1, term: $0), types, derivation) + } + return admitted + && rangeWithinWildcard( + derivedMin: restricted.effectiveOccurrenceMin(), + derivedMax: restricted.effectiveOccurrenceMax(), + wildcardMin: base.effectiveOccurrenceMin(), + wildcardMax: base.effectiveOccurrenceMax(), + ) + } +} diff --git a/Tests/Fixtures/schema-differential-xsts-baseline.txt b/Tests/Fixtures/schema-differential-xsts-baseline.txt index 57bc321..c27e74f 100644 --- a/Tests/Fixtures/schema-differential-xsts-baseline.txt +++ b/Tests/Fixtures/schema-differential-xsts-baseline.txt @@ -219,6 +219,8 @@ particlesEa025:../msData/particles/particlesEa025.xsd particlesEb015:../msData/particles/particlesEb015.xsd particlesEb040:../msData/particles/particlesEb040.xsd particlesEc009:../msData/particles/particlesEc009.xsd +particlesHa161:../msData/particles/particlesHa161.xsd +particlesM034:../msData/particles/particlesM034.xsd positiveInteger_fractionDigits004_1014:../msData/datatypes/Facets/Schemas/positiveInteger_fractionDigits004.xsd positiveInteger_fractionDigits007_1017:../msData/datatypes/Facets/Schemas/positiveInteger_fractionDigits007.xsd publicid00201m1:../sunData/Notation/publicId/publicId00201m/publicId00201m1.xsd diff --git a/Tests/XSDParticleChoiceRestrictionTests.swift b/Tests/XSDParticleChoiceRestrictionTests.swift new file mode 100644 index 0000000..d025243 --- /dev/null +++ b/Tests/XSDParticleChoiceRestrictionTests.swift @@ -0,0 +1,155 @@ +import Testing +@testable import PureXML + +/// The two regimes of RecurseAsIfGroup against a base choice (XSD 1.0 section +/// 3.9.6): a NON-repeating choice ({max occurs} = 1) gets the literal pairing, +/// a repeating choice keeps the effective-total-range reading (the particlesZ001 +/// ambiguity only exists when counts accumulate across repetitions). Shapes are +/// the XSTS cases that pin the boundary from both sides. +@Suite("XSD element-restricts-choice: literal vs effective-range regimes") +struct XSDParticleChoiceRestrictionTests { + private let xsNamespace = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + private func schema(base: String, derived: String) -> String { + """ + + \(base) + + \(derived) + + + + """ + } + + @Test("Optional element does not restrict a required branch of a non-repeating choice") + func test_ha161Rejected() { + // particlesHa161: derived a{0,1} vs branch a{1,1} in choice{0,1}. Pointwise + // NameAndTypeOK occurrence fails; the old effective-range reading accepted it. + let source = schema( + base: """ + + + + + """, + derived: "", + ) + #expect(throws: (any Error).self) { try PureXML.Schema.Document(source) } + } + + @Test("Same-occurrence element restricting one branch of a non-repeating choice compiles") + func test_ha160Accepted() throws { + // particlesHa160, the settled-valid twin: a{1,1} vs branch a{1,1}. + let source = schema( + base: """ + + + + + """, + derived: "", + ) + _ = try PureXML.Schema.Document(source) + } + + @Test("Element cannot map into a branch whose required sibling is unmapped") + func test_m034Rejected() { + // particlesM034: derived d1+ vs choice(seq(c1{2,2}, c2?), seq{1,inf}(d1*, d2+)). + // Every traversal of the only d1-bearing branch emits a d2, so pure d1 content + // is not in the base language; the leaf-admission reading accepted it. + let source = schema( + base: """ + + + + + + + + + + + """, + derived: "", + ) + #expect(throws: (any Error).self) { try PureXML.Schema.Document(source) } + } + + @Test("Element maps into a branch whose other siblings are emptiable") + func test_m035Accepted() throws { + // particlesM035, the settled-valid twin of M034: derived d2+ maps onto the + // branch's d2+ with the d1* sibling emptiable. + let source = schema( + base: """ + + + + + + + + + + + """, + derived: "", + ) + _ = try PureXML.Schema.Document(source) + } + + @Test("Element with matching in-branch occurrence restricts a non-repeating choice") + func test_m002ShapeAccepted() throws { + // particlesM002: derived c1{2,2} maps into branch seq{1,3}(c1{2,2}, c2?) of a + // non-repeating choice; the literal regime must keep accepting this. + let source = schema( + base: """ + + + + + + + + + + + """, + derived: "", + ) + _ = try PureXML.Schema.Document(source) + } + + @Test("Repeating base choice keeps the effective-range reading") + func test_z001ShapeAccepted() throws { + // particlesZ001's shape: element{0,inf} over choice{0,inf}(...). The literal + // pointwise rule would reject it; W3C reads it valid, and the repeating regime + // must keep accepting counts accumulated across choice repetitions. + let source = schema( + base: """ + + + + + """, + derived: "", + ) + _ = try PureXML.Schema.Document(source) + } + + @Test("Repeating base choice still bounds the effective total range") + func test_ha162ShapeRejected() { + // particlesHa162: a{1,3} over choice{1,2}(a|b): three a's exceed what two + // traversals can produce; the repeating regime's range check rejects it. + let source = schema( + base: """ + + + + + """, + derived: "", + ) + #expect(throws: (any Error).self) { try PureXML.Schema.Document(source) } + } +} diff --git a/Tests/XSTSSuiteExclusions.swift b/Tests/XSTSSuiteExclusions.swift index dd60fd1..5ccbbb2 100644 --- a/Tests/XSTSSuiteExclusions.swift +++ b/Tests/XSTSSuiteExclusions.swift @@ -79,7 +79,22 @@ extension XSTSSuiteTests { /// swap is invalid and Z008's `valid` has no spec text behind it (its annotation /// asserts "should be valid" without citing one). The schema is still compiled /// so the group's instance tests run; only its verdict is excluded. + /// + /// - `particlesIg004`: NameAndTypeOK clause 5 with base `block="#all"` and derived + /// `block="substitution extension restriction"`, expected `invalid`. Section + /// 3.3.2 maps `#all` to the {disallowed substitutions} value + /// {extension, restriction, substitution}, the identical set the derived + /// element's explicit list maps to, so the two property values are EQUAL and + /// clause 5's superset requirement holds; there is no spec path to `invalid`. + /// The expectation is jointly unsatisfiable with the suite's own settled + /// treatment of the mapped property values: `particlesIg001/002/003` (equal + /// explicit sets, `valid`) and `particlesIg005` (explicit triple restricted to + /// `#all`, `valid`, which requires `#all` to cover the triple). Only a reading + /// where `#all` is a distinct value strictly larger than the complete explicit + /// set reaches `invalid`, and no such value exists in the 1.0 blockSet grammar + /// (`XSDDerivation` compiles `#all` to exactly that triple). static let specDivergentSchemas: Set = [ "particlesZ008", + "particlesIg004", ] } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 92401b2..89bdfd5 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -59,7 +59,22 @@ struct XSTSSuiteTests { /// mismatch (elemZ026, particlesV020). particlesZ008, the identical construct /// with a settled `valid` expectation contradicting elemZ026's settled `invalid`, /// is reclassified into `specDivergentSchemas` below. - private let knownSchemaInvalidAccepted = 6 + /// 6 -> 3: RecurseAsIfGroup against a NON-repeating base choice ({max occurs} = 1) + /// now applies the literal §3.9.6 pairing instead of the effective-total-range + /// approximation, which only a repeating choice needs (the Z001 ambiguity is about + /// counts accumulated across repetitions, impossible at {max occurs} = 1). Closes + /// particlesHa161 (a derived `a{0,1}` is not a pointwise NameAndTypeOK restriction + /// of a branch `a{1,1}`) and particlesM034 (a derived `d1+` cannot map into the + /// branch `(d1*, d2+)` because the required `d2` is unmapped and not emptiable; its + /// settled-valid twin particlesM035, `d2+` over the same branch, still passes). + /// particlesIg004 (base `block="#all"` vs derived explicit triple) is reclassified + /// into `specDivergentSchemas`: section 3.3.2 maps both to the SAME {disallowed + /// substitutions} set, so clause 5 holds and `invalid` has no spec path. Remaining + /// three: Ik025/Ik027 (the cos-st-derived-ok 2.2.4 divergence above) and schN12 + /// (src-redefine 6.2.2 policy, see docs/xsts-deviations.md: enforcing 6.2.2 + /// uniformly would reject mgO013's settled-VALID schema, so the FP-safe policy + /// keeps it unenforced and schN12 stays an accepted deviation). + private let knownSchemaInvalidAccepted = 3 private let knownInstanceValidRejected = 0 /// Instance bucket (133 -> 22), per-step deltas in CHANGELOG.md: xsi:type must derive from the declared /// type; anyType cannot stand in for anySimpleType; an untyped substitutionGroup member inherits its head's diff --git a/docs/roadmap.md b/docs/roadmap.md index b889af6..73410c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,10 +11,11 @@ changelog entries and commit messages refer to that lost tracker. ## Current focus (priority order) -1. **[#4](https://codeberg.org/Mihaela/PureXML/issues/4)**: drive invalid - schemas accepted from 6 to 0. The remainder routes through - `ParticleRestriction.violation`'s deliberate under-approximation - (`docs/release-hurdles.md`, Part 1). +1. **[#4](https://codeberg.org/Mihaela/PureXML/issues/4)**: invalid schemas + accepted is at 3, and every remaining case is a documented divergence, not + an open bug: Ik025/Ik027 (cos-st-derived-ok 2.2.4 reading) and schN12 + (src-redefine 6.2.2 cannot be enforced uniformly without rejecting + mgO013's settled-valid schema). See `docs/xsts-deviations.md`. 2. **[#5](https://codeberg.org/Mihaela/PureXML/issues/5)**: drive invalid instances accepted from 4 to 0 (contested-spec and policy-dependent tail, `docs/xsts-deviations.md`). @@ -35,7 +36,7 @@ together. | Gate | Now | Issue | |---|---|---| | Valid schemas rejected | 0 | at floor, enforced by the ratchet | -| Invalid schemas accepted | 6 | [#4](https://codeberg.org/Mihaela/PureXML/issues/4) | +| Invalid schemas accepted | 3 | [#4](https://codeberg.org/Mihaela/PureXML/issues/4) | | Valid instances rejected | 0 | at floor, enforced by the ratchet | | Invalid instances accepted | 4 | [#5](https://codeberg.org/Mihaela/PureXML/issues/5) | diff --git a/docs/xsts-deviations.md b/docs/xsts-deviations.md index 27caf4e..dbca9ba 100644 --- a/docs/xsts-deviations.md +++ b/docs/xsts-deviations.md @@ -66,8 +66,11 @@ Schema side (10 cases): |---|---|---| | `particlesZ030_d` | rejects | Reference-aligned bug, now fixed: a `simpleContent` restriction relaxing a base `required` attribute to `optional` (derivation-ok-restriction.2). invalid-schemas-accepted 10 -> 9. | | `elemZ026`, `particlesV020` | accept too | Closed 2026-07-05: NameAndTypeOK clause 1 now pairs the DECLARED element references (`Particle.declaredReference` preserves the pre-substitution-expansion identity), so a restriction that swaps a head reference for a substitution-group member is rejected. Stricter than libxml2, spec-grounded (section 3.9.6 Elt:Elt clause 1 has no substitution allowance). The identical construct `particlesZ008` carries a settled `valid` expectation contradicting elemZ026's settled `invalid`; Z008 is reclassified as spec-divergent (excluded), with the full adjudication in `Tests/XSTSSuiteTests.swift`. | -| `particlesHa161`, `particlesIg004`, `particlesIk011/025/027`, `particlesM034` | accept too | Stricter-than-libxml2: NameAndTypeOK / particle-restriction edges (union membership, element-block superset) that libxml2 does not implement. Pursue only if Xerces-level strictness is wanted. | -| `schN12` | (multi-document) | Cross-namespace composition; not triaged in isolation. | +| `particlesIk011` | accept too | Closed at the 9 -> 8 step: NameAndTypeOK over a union base applies cos-st-derived-ok, so a separately-declared union whose members merely subset the base union's members is not a valid derivation. | +| `particlesIk025`, `particlesIk027` | accept too | Disclosed divergence, kept in the count: the restricting type derives from a union MEMBER's base, and PureXML accepts per cos-st-derived-ok clause 2.2.4; the XSTS `invalid` expectation reads the clause more literally (derivation must pass through the anonymous member itself). See the ratchet history in `Tests/XSTSSuiteTests.swift`. | +| `particlesHa161`, `particlesM034` | accept too | Closed 2026-07-05: RecurseAsIfGroup against a NON-repeating base choice ({max occurs} = 1) now applies the literal section 3.9.6 pairing; the effective-total-range approximation is kept only for a repeating choice, where the particlesZ001 ambiguity actually lives (counts accumulated across repetitions). Ha161: a derived `a{0,1}` is not a pointwise NameAndTypeOK restriction of the branch `a{1,1}`. M034: a derived `d1+` cannot map into `(d1*, d2+)` because the required `d2` is unmapped and not emptiable (its settled-valid twin M035, `d2+`, still passes). Stricter than libxml2. | +| `particlesIg004` | accept too | Reclassified spec-divergent (excluded, see `Tests/XSTSSuiteExclusions.swift`): section 3.3.2 maps `block="#all"` and the explicit `substitution extension restriction` to the SAME {disallowed substitutions} set, so NameAndTypeOK clause 5's superset holds and the `invalid` expectation has no spec path; jointly unsatisfiable with the settled Ig001/002/003 (equal sets, valid) and Ig005 (`#all` covers the triple, valid). | +| `schN12` | (multi-document) | Policy-dependent deviation, kept in the count (the schA7 precedent). A chameleon redefine of a no-namespace group whose redefining document is `elementFormDefault="qualified"`: the redefined group's locals land in ns-b while the originals are no-namespace, so src-redefine clause 6.2.2's restriction obligation fails NameAndTypeOK clause 1. PureXML does not enforce 6.2.2, and cannot uniformly: mgO013's settled-VALID schema also violates 6.2.2 (an `all{e1(1,1)}` redefining `all{e1(0,0)}`), so any uniform enforcement rejects a settled-valid schema (a valid-schema-rejected false positive, the hardest floor). The two settled expectations admit no single conformant policy; PureXML keeps the FP-safe one. | Instance side (sampled): `idZ010`, `mgO013`, `particlesZ001`, `reT17`, `reT38` are rejected by libxml2 (reference-aligned). `reT17`/`reT38` are now fixed: they