diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aebc1b..d729964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Schema/IdentityTupleSet.swift b/Sources/Schema/IdentityTupleSet.swift new file mode 100644 index 0000000..36cd0cb --- /dev/null +++ b/Sources/Schema/IdentityTupleSet.swift @@ -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 + } +} diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 361f1a4..d7bb9c2 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -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)) } diff --git a/Sources/Schema/Primitive.swift b/Sources/Schema/Primitive.swift index dcd54fc..fe9a57d 100644 --- a/Sources/Schema/Primitive.swift +++ b/Sources/Schema/Primitive.swift @@ -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? { diff --git a/Sources/Schema/SimpleType.swift b/Sources/Schema/SimpleType.swift index c78b81b..dd01dd0 100644 --- a/Sources/Schema/SimpleType.swift +++ b/Sources/Schema/SimpleType.swift @@ -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 `` performs. Ordered /// types (numeric, date/time) compare in value space, so `1` equals `01` diff --git a/Sources/Schema/StepSiblingIndex.swift b/Sources/Schema/StepSiblingIndex.swift new file mode 100644 index 0000000..f429dac --- /dev/null +++ b/Sources/Schema/StepSiblingIndex.swift @@ -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)] + } +} diff --git a/Tests/SchemaIdentityScaleDuplicateTests.swift b/Tests/SchemaIdentityScaleDuplicateTests.swift new file mode 100644 index 0000000..d161c1a --- /dev/null +++ b/Tests/SchemaIdentityScaleDuplicateTests.swift @@ -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" + ? "" + : "" + return try PureXML.Schema.Document(""" + + + + + \(content) + + + + + + """) + } + + @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 = "" + (0 ..< 500).map { "" }.joined() + "" + #expect(try schema.validate(distinct).isEmpty) + let repeated = "" + (0 ..< 499).map { "" }.joined() + "" + #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(""" + + + + + + + + + """) + func doc(_ first: String, _ second: String) -> String { + "" + + "\(first)\(second)" + } + #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("").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 = "" + (0 ..< 5).map { "" }.joined() + "" + 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) + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 00f1cbb..986b339 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -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.