perf(schema): make xsd identity-constraint validation linear (#312)

xs:unique/xs:key/xs:keyref were quadratic over a wide list of targets.
The duplicate check compared each value tuple against every tuple already
seen (and a keyref against every referenced key), and the error-location
helper rescanned the parent's whole child list to position each target.

Key tuples in a Set by their raw string when field equality provably
reduces to a raw string comparison (whitespace-preserving lexical types,
no colon, which is most ids); value-space types (numeric, boolean, date),
whitespace-collapsing types, and QName-bearing values keep the exact
pairwise comparison, so which documents pass is unchanged. Position each
target through a per-parent, per-name index built once.

A 16000-item xs:unique document drops from about 4.0 s to about 0.12 s
(~34x), and the cost is now linear. The full local suite and the W3C
XSTS datatype/identity conformance corpus are unchanged.
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 02:45:17 +02:00 committed by GitHub
parent e080876eac
commit d1a371a6a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 251 additions and 12 deletions

View File

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- XSD identity-constraint validation (`xs:unique`, `xs:key`, `xs:keyref`) is no longer quadratic over a wide list of targets. Two scans drove the cost: the duplicate check compared each new value tuple against every tuple already seen (a `keyref` likewise scanned every referenced key), and the error-location helper rescanned the parent's whole child list to position each target. The first now keys tuples in a `Set` by their raw string, which is sound because field equality reduces to a raw `string == string` comparison for whitespace-preserving lexical types (most ids); value-space types (numeric, boolean, date), whitespace-collapsing types, and QName-bearing values keep the exact pairwise comparison, so which documents pass is unchanged. The second positions each target through a per-parent, per-name index built once. Validating a 16000-item document against an `xs:unique` drops from about 4.0 s to about 0.12 s (~34x), and the cost is now linear. Guarded by `SchemaIdentityScaleDuplicateTests`.
## [0.4.6] - 2026-06-26
### Fixed

View File

@ -0,0 +1,57 @@
/// A membership set for identity-constraint value tuples that detects an equal
/// tuple in (amortized) O(1) for the common case instead of scanning every
/// stored tuple, which made `unique`/`key`/`keyref` validation quadratic over a
/// wide list (one duplicate scan per target, each linear in the targets seen).
///
/// `FieldValue` equality is value-space aware (`3.0` equals `3`, `true` equals
/// `1`, a QName equals an equivalent prefix), so it cannot be hashed directly: a
/// consistent hash would have to canonicalize each value space, and the QName
/// case even depends on the other operand. Instead the set keys only the tuples
/// it can prove compare as raw strings, and keeps the exact pairwise comparison
/// for the rest, so behavior is identical to the former linear scan.
///
/// A field compares as a raw string when it is type-less (lexical equality) or
/// an atomic, lexically-compared, whitespace-preserving type (so `valueMatches`
/// reduces to `string == string`), and carries no colon that could trigger QName
/// resolution. Two such tuples are equal exactly when their raw strings match,
/// so they live in a `Set` keyed by those strings. Tuples with any value-space,
/// whitespace-collapsing, or QName-bearing field are kept verbatim and compared
/// with `==`; a raw-string query still scans them, and a value-space query scans
/// everything, so no equal pair is ever missed.
struct TupleSet {
private var rawKeys: Set<[String]> = []
private var unsafe: [[FieldValue?]] = []
/// Every inserted tuple, consulted only when a value-space query must be
/// compared against the raw-string-keyed tuples too.
private var all: [[FieldValue?]] = []
/// Whether a stored tuple is equal to `tuple` under `FieldValue` equality.
func contains(_ tuple: [FieldValue?]) -> Bool {
if let key = Self.rawKey(of: tuple) {
return rawKeys.contains(key) || unsafe.contains { $0 == tuple }
}
return all.contains { $0 == tuple }
}
mutating func insert(_ tuple: [FieldValue?]) {
if let key = Self.rawKey(of: tuple) {
rawKeys.insert(key)
} else {
unsafe.append(tuple)
}
all.append(tuple)
}
/// The raw-string key of a tuple every one of whose fields compares as a raw
/// string, or nil when any field needs the value-space comparison.
private static func rawKey(of tuple: [FieldValue?]) -> [String]? {
var key: [String] = []
key.reserveCapacity(tuple.count)
for field in tuple {
guard let field, !field.string.contains(":") else { return nil }
if let type = field.type, !type.comparesRawLexically { return nil }
key.append(field.string)
}
return key
}
}

View File

@ -20,6 +20,12 @@ extension PureXML.Schema {
let types: [String: ElementType]
/// Selector and field XPaths compiled once per run, not per element.
private let cache = XPathQueryCache()
/// Same-name sibling positions for error-location paths, indexed once per
/// parent and name. ``steps`` looks a target's position up here instead of
/// rescanning the parent's children every call, which made locating each
/// of a wide list's targets O(n) and the whole `unique`/`key`/`keyref`
/// pass quadratic.
let stepIndex = StepSiblingIndex()
init(
constraints: [String: [IdentityConstraint]],
@ -115,11 +121,6 @@ extension PureXML.Schema {
return []
}
private func isNonRef(_ constraint: IdentityConstraint) -> Bool {
if case .keyref = constraint.kind { return false }
return true
}
private func collect(
_ constraint: IdentityConstraint,
at node: PureXML.Model.TreeNode,
@ -129,7 +130,7 @@ extension PureXML.Schema {
) {
guard isNonRef(constraint) else { return }
var tuples: [[FieldValue?]] = []
var seen: [[FieldValue?]] = []
var seen = TupleSet()
for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) {
let targetPath = path + steps(from: node, to: target)
if let error = fieldError(constraint, at: target, path: targetPath) {
@ -145,13 +146,13 @@ extension PureXML.Schema {
continue
}
if tuple.contains(where: { $0 == nil }) { continue }
if seen.contains(where: { $0 == tuple }) {
if seen.contains(tuple) {
issues.append(.init(
reason: "\(label(constraint)) '\(constraint.name)': duplicate value",
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
))
} else {
seen.append(tuple)
seen.insert(tuple)
}
tuples.append(tuple)
}
@ -167,6 +168,10 @@ extension PureXML.Schema {
) {
guard case let .keyref(refer) = constraint.kind else { return }
let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? []
var keys = TupleSet()
for keyTuple in keyTuples {
keys.insert(keyTuple)
}
for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) {
let targetPath = path + steps(from: node, to: target)
if let error = fieldError(constraint, at: target, path: targetPath) {
@ -175,7 +180,7 @@ extension PureXML.Schema {
}
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
if tuple.contains(where: { $0 == nil }) { continue }
if !keyTuples.contains(where: { $0 == tuple }) {
if !keys.contains(tuple) {
issues.append(.init(
reason: "keyref '\(constraint.name)': no matching key '\(refer)'",
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
@ -330,6 +335,13 @@ extension PureXML.Schema {
}
extension PureXML.Schema.IdentityValidator {
/// Whether a constraint collects tuples (`key`/`unique`) rather than
/// referencing them (`keyref`).
func isNonRef(_ constraint: PureXML.Schema.IdentityConstraint) -> Bool {
if case .keyref = constraint.kind { return false }
return true
}
/// The coding-path steps from the constraint-bearing `container` down to a
/// selected `target`, so an identity error locates the actual offending
/// element rather than the element that merely declares the constraint.
@ -346,9 +358,8 @@ extension PureXML.Schema.IdentityValidator {
var parent = container
for node in chain.reversed() {
let name = node.name?.description ?? ""
let siblings = parent.children.filter { $0.kind == .element && ($0.name?.description ?? "") == name }
if siblings.count > 1, let index = siblings.firstIndex(where: { $0 === node }) {
steps.append(.element(name, index: index + 1))
if let index = stepIndex.position(of: node, under: parent, name: name) {
steps.append(.element(name, index: index))
} else {
steps.append(.element(name))
}

View File

@ -40,6 +40,18 @@ extension PureXML.Schema {
stringValidity(value) ?? numericValidity(value)
}
/// Whether two valid values of this primitive compare by their
/// whitespace-processed lexical form rather than by a value space. These
/// are exactly the `default` cases of `SimpleType.atomicValueMatches`:
/// every primitive except the numeric, date, and boolean spaces, which
/// compare two distinct lexical forms (`3.0`/`3`, `true`/`1`) as equal.
var comparesLexically: Bool {
switch self {
case .decimal, .integer, .double, .float, .boolean, .dateKind: false
default: true
}
}
/// Validity for the string-like and boolean spaces; nil when this primitive
/// is handled by ``numericValidity(_:)``.
private func stringValidity(_ value: String) -> Bool? {

View File

@ -137,6 +137,20 @@ public extension PureXML.Schema {
return nil
}
/// Whether two values of this type are equal exactly when their raw
/// strings match: an atomic, lexically-compared type whose effective
/// `whiteSpace` is `preserve`, so `valueMatches` reduces `process` to the
/// identity and compares the strings verbatim. Identity-constraint
/// duplicate detection keys such fields by their raw string in a `Set`
/// (O(1) per value) instead of an O(n) pairwise `valueMatches` scan; the
/// value-space and whitespace-collapsing types return false and keep the
/// exact pairwise comparison.
var comparesRawLexically: Bool {
guard case .atomic = variety, base.primitive.comparesLexically else { return false }
if case .preserve = facets.whiteSpace ?? base.whiteSpace { return true }
return false
}
/// Whether `instance` and `literal` denote the same value in this type's
/// value space, the comparison a RELAX NG `<value>` performs. Ordered
/// types (numeric, date/time) compare in value space, so `1` equals `01`

View File

@ -0,0 +1,39 @@
/// Caches, per parent element and child local name, the one-based position of
/// each same-name element child among its like-named siblings, used to locate an
/// identity-constraint error precisely (`item[3]` rather than `item`).
///
/// Without it, ``IdentityValidator/steps(from:to:)`` rescans a parent's whole
/// child list for every target it locates, so validating a wide list of like-
/// named elements is O(n) per target and quadratic overall. The first lookup
/// under a given parent and name builds the position table once; later lookups
/// are dictionary reads. A position is recorded only when more than one element
/// shares the name, matching the path grammar, which omits the predicate for a
/// unique child.
final class StepSiblingIndex {
typealias Tree = PureXML.Model.TreeNode
/// parent identity -> child local name -> child identity -> one-based
/// position among same-name element siblings (present only when several
/// siblings share the name).
private var positions: [ObjectIdentifier: [String: [ObjectIdentifier: Int]]] = [:]
/// The one-based position of `node` among `parent`'s element children named
/// `name`, or nil when it is the only such child (no predicate needed).
func position(of node: Tree, under parent: Tree, name: String) -> Int? {
let parentIdentity = ObjectIdentifier(parent)
if let table = positions[parentIdentity]?[name] {
return table[ObjectIdentifier(node)]
}
var table: [ObjectIdentifier: Int] = [:]
var ordinal = 0
for child in parent.children where child.kind == .element && (child.name?.description ?? "") == name {
ordinal += 1
table[ObjectIdentifier(child)] = ordinal
}
// The grammar predicates a step only when the name is ambiguous; a single
// same-name child keeps the bare `name`, so drop a lone entry.
if ordinal < 2 { table = [:] }
positions[parentIdentity, default: [:]][name] = table
return table[ObjectIdentifier(node)]
}
}

View File

@ -0,0 +1,82 @@
import Testing
@testable import PureXML
/// `unique`/`key`/`keyref` detect duplicates through a raw-string `Set` fast path
/// for whitespace-preserving lexical fields (most ids) and an exact value-space
/// scan for the rest, so a wide list validates in linear time without changing
/// which documents pass. These pin that the fast path and the fallback agree:
/// raw-string duplicates are caught, value-space and whitespace-collapsing
/// duplicates that the fast path deliberately skips are still caught by the
/// fallback, and a colliding target keeps its positional error location.
@Suite("Identity-constraint duplicate detection across the fast path and fallback")
struct SchemaIdentityScaleDuplicateTests {
private func uniqueSchema(field: String, type: String) throws -> PureXML.Schema.Document {
let content = field == "@v"
? "<xsd:attribute name=\"v\" type=\"\(type)\"/>"
: "<xsd:simpleContent><xsd:extension base=\"\(type)\"/></xsd:simpleContent>"
return try PureXML.Schema.Document("""
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<xsd:element name="catalog">
<xsd:complexType><xsd:sequence>
<xsd:element name="item" maxOccurs="unbounded">
<xsd:complexType>\(content)</xsd:complexType>
</xsd:element>
</xsd:sequence></xsd:complexType>
<xsd:unique name="u"><xsd:selector xpath="item"/><xsd:field xpath="\(field)"/></xsd:unique>
</xsd:element>
</xsd:schema>
""")
}
@Test("a wide list of distinct string ids passes; one repeat is caught")
func test_rawFastPath() throws {
let schema = try uniqueSchema(field: "@v", type: "xsd:string")
let distinct = "<catalog>" + (0 ..< 500).map { "<item v=\"i\($0)\"/>" }.joined() + "</catalog>"
#expect(try schema.validate(distinct).isEmpty)
let repeated = "<catalog>" + (0 ..< 499).map { "<item v=\"i\($0)\"/>" }.joined() + "<item v=\"i0\"/></catalog>"
#expect(try !schema.validate(repeated).isEmpty)
}
@Test("a whitespace-collapsing duplicate the raw path skips is caught by the fallback")
func test_collapseFallback() throws {
// xsd:token collapses internal runs, so "a b" and "a b" are one value.
// Selected by xsi:type so the field carries the token type, it is not
// whitespace-preserving and so bypasses the raw-string Set, colliding only
// through the exact value-space scan.
let schema = try PureXML.Schema.Document("""
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<xsd:element name="catalog">
<xsd:complexType><xsd:sequence>
<xsd:element name="item" type="xsd:anyType" maxOccurs="unbounded"/>
</xsd:sequence></xsd:complexType>
<xsd:unique name="u"><xsd:selector xpath="item"/><xsd:field xpath="."/></xsd:unique>
</xsd:element>
</xsd:schema>
""")
func doc(_ first: String, _ second: String) -> String {
"<catalog xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
+ "<item xsi:type=\"xsd:token\">\(first)</item><item xsi:type=\"xsd:token\">\(second)</item></catalog>"
}
#expect(try !schema.validate(doc("a b", "a b")).isEmpty)
#expect(try schema.validate(doc("a b", "c d")).isEmpty)
}
@Test("a colon-bearing string duplicate is still caught")
func test_colonValue() throws {
// A value with a colon is conservatively routed to the value-space scan
// (it could be a QName), so duplicates must still be detected.
let schema = try uniqueSchema(field: "@v", type: "xsd:string")
#expect(try !schema.validate("<catalog><item v=\"a:b\"/><item v=\"a:b\"/></catalog>").isEmpty)
}
@Test("a duplicate among many same-name siblings keeps its positional location")
func test_errorLocationPredicate() throws {
let schema = try uniqueSchema(field: "@v", type: "xsd:string")
let doc = "<catalog>" + (0 ..< 5).map { "<item v=\"i\($0)\"/>" }.joined() + "<item v=\"i0\"/></catalog>"
let errors = try schema.validate(doc)
#expect(errors.count == 1)
// The sixth item collides; its located path names item[6], proving the
// cached sibling index numbers the colliding target correctly.
#expect(errors.first.map { "\($0)".contains("item") && "\($0)".contains("6") } == true)
}
}

View File

@ -117,3 +117,23 @@ wide parent. Tens of thousands of single-node extractions each rescanned the
makes it linear: ~2.3 s -> ~0.65 s (~3.5x), output byte-identical (Xalan
gold-output verified). The fix is the same `count <= 1` early-out that
`sortedByDocumentOrder()` already had.
## XSD identity constraints (quadratic fix, found by per-operation scale-testing)
Validating a wide list against an `xs:unique`/`xs:key`/`xs:keyref` was
quadratic for two independent reasons, both invisible on the default corpus
(which has no identity constraints) and surfaced only by scale-testing the
operation on its own at 2x/4x/8x sizes. First, the duplicate check compared
each new value tuple against every tuple already collected (and a `keyref`
scanned every referenced key), so n targets cost O(n^2). Field equality is
value-space aware (`3.0` equals `3`, `true` equals `1`, a QName equals an
equivalent prefix), so the tuples cannot be hashed directly. The fix keys only
the tuples that provably compare as raw strings (whitespace-preserving lexical
types with no colon, which is most ids) in a `Set`, and keeps the exact
pairwise comparison for value-space, whitespace-collapsing, and QName-bearing
fields, so which documents pass is unchanged. Second, the error-location helper
rescanned the parent's whole child list to position each target (`item[3]`),
another O(n) per target; it now uses a per-parent, per-name index built once.
Together: a 16000-item `xs:unique` document drops from ~4.0 s to ~0.12 s
(~34x), and the cost is now linear. The full local suite and the W3C XSTS
datatype/identity conformance corpus are unchanged.