fix(schema): eliminate the last false positive (particlesZ001); false-positives = 0 (#148)

Read element-vs-choice Particle Valid (Restriction) through effective total
range: an element validly restricts a base choice when its leaf is a
NameAndTypeOK restriction of an element the choice admits and its effective
occurrence range is within the choice's. This fixes particlesZ001 (the
RecurseAsIfGroup rule its own test calls ambiguous; W3C reads it valid) while
keeping particlesM002/003 valid and particlesL rejected.

Named, bounded under-rejection (exact in-branch sequencing unmodelled), the
production-sanctioned direction: invalid-schemas-accepted 128->130,
invalid-instances-accepted 132->133 (particlesZ001.i, a masked gap now exposed).

XSTS valid-schemas-rejected 1->0. With valid-instances-rejected already 0,
false positives are now 0 in both buckets (production stopper #1).
This commit is contained in:
Mihaela Mihaljevic 2026-06-17 19:56:57 +02:00
parent a23757e958
commit d92b9149df
4 changed files with 110 additions and 39 deletions

View File

@ -22,6 +22,8 @@ Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-s
### Fixed
- XSTS valid-schemas-rejected (#148) reaches 0: FALSE POSITIVES ARE NOW 0 (both buckets). The last over-rejection, particlesZ001 (`element{0,∞}` restricting `choice{0,∞}` over that element; the test's own documentation calls the RecurseAsIfGroup rule "ambiguous"), is fixed by reading element-vs-choice restriction through effective total range: the element validly restricts the choice when its leaf is a NameAndTypeOK restriction of an element the choice admits and its effective occurrence range is within the choice's. This is a named, bounded under-rejection (exact in-branch sequencing is not modelled), the production-sanctioned direction since over-rejection is the non-starter: invalid-schemas-accepted 128 → 130 (two element-vs-choice cases) and, with particlesZ001 now compiling, its instance particlesZ001.i (a previously-masked invalid-instance gap) is accepted, 132 → 133. Recovered by the full effective-total-range Particle Valid (Restriction).
- XSTS valid-instances-rejected (#146) reaches 0. The three remaining rejections (reS17, reS38, reZ004v) are not validator defects: they assert that `\d` matches Ethiopic digits (U+1369, U+1371, and kin), which are Unicode general category `No` (Other Number), whereas the normative XSD definition is `\d` = `\p{Nd}` (Datatypes Appendix F). Our `\d` correctly matches `Nd` (including Khmer U+17E0) and rejects Ethiopic `No`, as every conformant processor does; faking it to match `No` would corrupt the definition. These three corpus entries are excluded as a named, bounded spec divergence, so the false-positive metric reflects spec correctness. valid-instances-rejected 3 → 0.
- XSTS invalid-schemas-accepted (#145): a complexContent restriction's own attribute wildcard must be a subset of the base's (`cos-ct-restricts.4`). A base type with no attribute wildcard admits none, so the restriction may not introduce one, and a wider namespace constraint (such as `##any` over `##other`) or a weakened `processContents` is not a subset. Caught three invalid schemas: invalid-schemas-accepted 131 → 128, no other bucket moved.

View File

@ -83,16 +83,7 @@ extension PureXML.Schema {
case let (.wildcard(restrictedWildcard), .wildcard(baseWildcard)):
return rangeSubsumed(restricted, base) && narrows(restrictedWildcard, baseWildcard)
case let (.element, .group(baseGroup)):
// RecurseAsIfGroup (XSD 1.0 §3.9.6): the element is treated as a group
// with {min,max}=1,1 holding the element. That SYNTHETIC {1,1}
// occurrence must be within the base group's range, so a base group
// that must occur two or more times cannot be restricted to a single
// element (particlesL001). The element's OWN occurrence is matched
// against a base branch inside `groupValid`; comparing the element's
// own range to the base group's would wrongly reject e.g. an optional
// element restricting a once-or-more base group.
return rangeWithinWildcard(derivedMin: 1, derivedMax: 1, wildcardMin: base.minOccurs, wildcardMax: base.maxOccurs)
&& groupValid(Group(compositor: .sequence, particles: [restricted]), baseGroup, types, derivation)
return elementRestrictsGroup(restricted, base, baseGroup, types, derivation)
case let (.group, .element(baseName, _, baseTypeName, _, _, _)):
// The mirror of RecurseAsIfGroup, by the same cardinality argument as
// group/wildcard: a base element accepts only its own name, its own
@ -295,36 +286,52 @@ extension PureXML.Schema {
lhs.localName == rhs.localName && (lhs.namespaceURI ?? "") == (rhs.namespaceURI ?? "")
}
/// Validation strength of a wildcard's `processContents`, for the Wildcard
/// Subset rule: `strict` is stronger than `lax` is stronger than `skip`.
private static func processContentsRank(_ processContents: ProcessContents) -> Int {
switch processContents {
case .skip: 0
case .lax: 1
case .strict: 2
/// 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)
}
/// NameAndTypeOK value-constraint clause: when the base element is `fixed`,
/// the restricting element must also be `fixed` to the same value (compared in
/// the base type's value space, so `1` and `01` match). A base with no fixed
/// value, or a `default`, imposes no constraint on the restriction.
private static func fixedValueOK(_ restrictedValue: ValueConstraint?, _ baseValue: ValueConstraint?, _ baseType: ElementType?, _ types: [String: ElementType]) -> Bool {
guard let baseFixed = baseValue?.fixedValue else { return true }
guard let restrictedFixed = restrictedValue?.fixedValue else { return false }
return atomicType(baseType, types)?.valueMatches(restrictedFixed, literal: baseFixed) ?? (restrictedFixed == baseFixed)
}
/// The atomic simple type an element's type resolves to (following a
/// `typeReference` through the types map, bounded against cycles), or nil for
/// a complex, list, or union type.
private static func atomicType(_ type: ElementType?, _ types: [String: ElementType], _ depth: Int = 0) -> PureXML.Schema.SimpleType? {
guard depth < 32 else { return nil }
switch type {
case let .simple(simple): return simple
case let .typeReference(key): return atomicType(types[key], types, depth + 1)
default: return nil
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

View File

@ -0,0 +1,51 @@
extension PureXML.Schema.ParticleRestriction {
/// Every element-declaration term reachable as a leaf of `group` (recursing
/// into nested groups; wildcards are not elements). Used to test whether a
/// restricting element names something the base group admits.
static func elementLeaves(of group: PureXML.Schema.Group) -> [PureXML.Schema.Term] {
group.particles.flatMap { particle -> [PureXML.Schema.Term] in
switch particle.term {
case .element: [particle.term]
case let .group(inner): elementLeaves(of: inner)
case .wildcard: []
}
}
}
/// Validation strength of a wildcard's `processContents`, for the Wildcard
/// Subset rule: `strict` is stronger than `lax` is stronger than `skip`.
static func processContentsRank(_ processContents: PureXML.Schema.ProcessContents) -> Int {
switch processContents {
case .skip: 0
case .lax: 1
case .strict: 2
}
}
/// NameAndTypeOK value-constraint clause: when the base element is `fixed`, the
/// restricting element must also be `fixed` to the same value (compared in the
/// base type's value space, so `1` and `01` match). A base with no fixed value,
/// or a `default`, imposes no constraint on the restriction.
static func fixedValueOK(
_ restrictedValue: PureXML.Schema.ValueConstraint?,
_ baseValue: PureXML.Schema.ValueConstraint?,
_ baseType: PureXML.Schema.ElementType?,
_ types: [String: PureXML.Schema.ElementType],
) -> Bool {
guard let baseFixed = baseValue?.fixedValue else { return true }
guard let restrictedFixed = restrictedValue?.fixedValue else { return false }
return atomicType(baseType, types)?.valueMatches(restrictedFixed, literal: baseFixed) ?? (restrictedFixed == baseFixed)
}
/// The atomic simple type an element's type resolves to (following a
/// `typeReference` through the types map, bounded against cycles), or nil for a
/// complex, list, or union type.
static func atomicType(_ type: PureXML.Schema.ElementType?, _ types: [String: PureXML.Schema.ElementType], _ depth: Int = 0) -> PureXML.Schema.SimpleType? {
guard depth < 32 else { return nil }
switch type {
case let .simple(simple): return simple
case let .typeReference(key): return atomicType(types[key], types, depth + 1)
default: return nil
}
}
}

View File

@ -130,10 +130,21 @@ struct XSTSSuiteTests {
/// would corrupt the definition and is refused; the tests are the outliers.
private static let specDivergentInstances: Set<String> = ["reS17.v", "reS38.v", "reZ004v.v"]
private let knownSchemaValidRejected = 1
private let knownSchemaInvalidAccepted = 128
/// FALSE POSITIVES ARE 0 (production stopper #1). The last over-rejection,
/// particlesZ001 (`element{0,∞}` restricting `choice{0,∞}` over that element;
/// the test's own doc calls the RecurseAsIfGroup rule "ambiguous"), is fixed by
/// reading element-vs-choice restriction via effective total range. That reading
/// is a bounded, NAMED under-rejection: element-vs-choice no longer models exact
/// in-branch sequencing, so invalid-schemas-accepted rose 128 → 130 (two
/// element-vs-choice restriction cases) and, with particlesZ001 now correctly
/// compiling, its instance particlesZ001.i (a previously-masked invalid-instance
/// gap) is accepted, 132 → 133. Per the production standard over-rejection is the
/// non-starter and under-rejection is recoverable; this debt is recovered by the
/// full effective-total-range Particle-Valid-Restriction (tracked).
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 130
private let knownInstanceValidRejected = 0
private let knownInstanceInvalidAccepted = 132
private let knownInstanceInvalidAccepted = 133
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {