PureXML/Sources/Schema/ContentModelDeterminism.swift
Mihaela Mihaljevic 6f8222a840 feat(schema): compositional UPA determinism, delete positionCap
The Unique Particle Attribution check no longer inlines group references or caps.
A new CompositionalDeterminism engine computes first/followlast over particle
identities and summarizes each xs:group once, so a multiply-referenced nested
group whose inlined Glushkov automaton would be 2^K positions is checked in
O(nodes * particles^2) time. ContentModelDeterminism now delegates to it and the
inlining position automaton plus positionCap are deleted, closing the last named
interactive-safety resource-bound blocker (production-readiness stopper #4) and
the silent under-rejection the cap caused (stopper #3).

The compositional engine was proven verdict-equivalent to the prior inlining
automaton by a differential over the entire XSTS corpus (zero divergences) before
replacing it. The followlast detail was essential: a first attempt that emitted a
repeating end-item's loop-back and the suffix-first as separate decision sets
under-detected 13 XSTS content models; the differential caught every one.

Verification: XSTS gate unchanged (invalid-schemas-accepted 15, valid-schemas-
rejected 0, instance buckets held); SchemaDeterminismGroupBlowupTests compiles a
2^20-position model without hanging and rejects an ambiguous one the old cap would
have skipped (WASI uses a smaller depth, native time limits gated off WASI); full
suite, style, namespacing, and WASM all pass. The per-particle model and QName-only
overlap are preserved, so the false-positive direction stays at zero.
2026-06-22 22:19:44 +02:00

74 lines
4.1 KiB
Swift

public extension PureXML.Schema {
/// Unique Particle Attribution (cos-nonambig, XSD 1.0 3.8.6): a content model
/// must be deterministic, so each element in an instance is attributed to one
/// particle without lookahead. The model is ambiguous iff some decision set (the
/// content's `first`, or any item's `followpos`) holds two distinct particles
/// whose labels can match the same element.
///
/// The check delegates to ``PureXML/Schema/CompositionalDeterminism``, which
/// computes `first`/`followlast` over PARTICLE identities WITHOUT inlining
/// `<xs:group ref>`, so a multiply-referenced nested group cannot blow up to
/// `2^K` positions. That gives a proven `O(nodes * particles^2)` bound with no
/// position cap and no silent skip (production-readiness stopper #4). The
/// compositional engine was proven verdict-equivalent to the prior inlining
/// Glushkov automaton by a differential over the whole XSTS corpus (zero
/// divergences) before it became authoritative; see
/// docs/design/counted-content-automaton.md.
///
/// It works on a LITERAL view of the raw schema tree, distinct from the compiled
/// (instance) content model: a `ref` is one particle carrying its resolved QName
/// (prefix bindings, not the blanket target namespace), and substitution groups
/// are NOT expanded. The overlap test is QName-only, so it can only under-report
/// substitution-group ambiguities, never over-report.
enum ContentModelDeterminism {
/// Every UPA-violation finding across the schema's complex-type content
/// models, deduplicated by message in first-seen order. Each finding is
/// located on the content-model node (`sequence`/`choice`/`all`/`group`)
/// whose particles are ambiguous; a message shared by two models locates on
/// the first, preserving the deduplicated count.
static func violationFindings(in schema: XSDTree, context: XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] {
let bindings = namespaceBindings(schema)
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
var seen: Set<String> = []
forEachComplexType(schema) { complexType in
let models = effectiveModelNodes(complexType, bindings, context, [])
guard !models.isEmpty,
let conflict = PureXML.Schema.CompositionalDeterminism.violation(models, bindings, context)
else { return }
// The conflicting particle the author can act on is in this type's own
// content, so locate on its own content-model node.
let node = contentModelNode(complexType) ?? complexType
if seen.insert(conflict).inserted {
findings.append(PureXML.Schema.SchemaLocatedFinding(reason: conflict, node: node))
}
}
return findings
}
/// The prefix-to-namespace bindings declared on the schema element (its
/// `xmlns:prefix` attributes; the default namespace under the empty key).
private static func namespaceBindings(_ schema: XSDTree) -> [String: String] {
var bindings: [String: String] = [:]
for attribute in schema.attributes {
if attribute.name.prefix == "xmlns" {
bindings[attribute.name.localName] = attribute.value
} else if attribute.name.prefix == nil, attribute.name.localName == "xmlns" {
bindings[""] = attribute.value
}
}
return bindings
}
/// Visits every `complexType` element in the schema document, skipping
/// foreign annotation content.
private static func forEachComplexType(_ node: XSDTree, _ visit: (XSDTree) -> Void) {
let local = PureXML.Schema.XSDNode.localName(node)
if local == "appinfo" || local == "documentation" { return }
if local == "complexType" { visit(node) }
for child in PureXML.Schema.XSDNode.elementChildren(node) {
forEachComplexType(child, visit)
}
}
}
}