perf(xpath): compute document-order keys once per sort

Node-set ordering previously recomputed both nodes' root paths on every
precedes comparison (treePath is O(depth x siblings), so sorts were
O(n log n x depth)). Add decorate-sort-undecorate helpers,
sortedByDocumentOrder() and firstInDocumentOrder(), that compute each node's
order key once, and switch the evaluator's order-and-dedup, the EXSLT set
functions, string-value coercion, and NodeFunctions' target lookup to them.
set:leading/trailing also decorate once, reusing the keys for the pivot
filter and the final sort. precedes stays for pairwise use, sharing the
key comparator (Node.ordered). Behavior unchanged, all suites green.

Closes #113
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 03:39:19 +02:00
parent 0edc1f77f9
commit b8dbf8e97a
6 changed files with 43 additions and 13 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Document-order sorts no longer recompute root paths per comparison (#113): node-set ordering (the evaluator's order-and-dedup, EXSLT's set functions, string-value coercion's first-node lookup) goes through decorate-sort-undecorate helpers (`sortedByDocumentOrder()`, `firstInDocumentOrder()`) that compute each node's order key once, turning O(n log n x depth) sorts into O(n log n). `set:leading`/`set:trailing` also reuse one precomputed pivot key. Behavior is unchanged.
- XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path.
- The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees.
- DTD attribute validation is now decomposed into five named, independently composable `Validation` rules (#101): `DTD.requiredAttributes`, `fixedAttributeValues`, `enumeratedAttributeValues`, `tokenizedAttributeTypes`, and `notationAttributes`, replacing the single `attributeDeclarations` rule. Each is removable by identity and isolation-tested one constraint at a time, honoring the validation-framework idiom. Behavior is unchanged. (XSD content validation stays one recursive rule by a documented scope decision, since its constraints are interdependent through type resolution; Schematron already exposes its rules as composable `Validation<Node, Void>` values.)

View File

@ -63,7 +63,7 @@ extension PureXML.XPath {
guard !target.isNaN else { return [] }
return nodes
.filter { PureXML.XPath.Value.parseNumber($0.stringValue) == target }
.sorted(by: Node.precedes)
.sortedByDocumentOrder()
}
// MARK: sets
@ -84,7 +84,7 @@ extension PureXML.XPath {
guard let nodes = value?.nodes else { return [] }
var seen: Set<String> = []
var result: [Node] = []
for node in nodes.sorted(by: Node.precedes) where seen.insert(node.stringValue).inserted {
for node in nodes.sortedByDocumentOrder() where seen.insert(node.stringValue).inserted {
result.append(node)
}
return result
@ -93,13 +93,13 @@ extension PureXML.XPath {
private static func difference(_ arguments: [Value]) -> [Node] {
let first = arguments.first?.nodes ?? []
let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : [])
return first.filter { !second.contains($0) }.sorted(by: Node.precedes)
return first.filter { !second.contains($0) }.sortedByDocumentOrder()
}
private static func intersection(_ arguments: [Value]) -> [Node] {
let first = arguments.first?.nodes ?? []
let second = Set(arguments.count > 1 ? arguments[1].nodes ?? [] : [])
return first.filter { second.contains($0) }.sorted(by: Node.precedes)
return first.filter { second.contains($0) }.sortedByDocumentOrder()
}
private static func hasSameNode(_ arguments: [Value]) -> Bool {
@ -111,12 +111,17 @@ extension PureXML.XPath {
/// (or follow) the first node, in document order, of the second set.
private static func relative(_ arguments: [Value], leading: Bool) -> [Node] {
let first = arguments.first?.nodes ?? []
guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).min(by: Node.precedes) else {
guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).firstInDocumentOrder() else {
return []
}
// Decorate once: each node's order key is computed a single time and
// reused for both the pivot comparison and the final sort.
let pivotKey = pivot.documentOrder
return first
.filter { leading ? Node.precedes($0, pivot) : Node.precedes(pivot, $0) }
.sorted(by: Node.precedes)
.map { (node: $0, key: $0.documentOrder) }
.filter { leading ? Node.ordered($0.key, before: pivotKey) : Node.ordered(pivotKey, before: $0.key) }
.sorted { Node.ordered($0.key, before: $1.key) }
.map(\.node)
}
}
}

View File

@ -162,7 +162,7 @@ extension PureXML.XPath {
for node in nodes where seen.insert(node).inserted {
unique.append(node)
}
return unique.sorted(by: Node.precedes)
return unique.sortedByDocumentOrder()
}
private static func root(of node: Node) -> Node {

View File

@ -30,7 +30,7 @@ extension PureXML.XPath {
private static func target(_ arguments: [Value], _ context: EvaluationContext) -> Node? {
if arguments.isEmpty { return context.node }
return arguments.first?.nodes?.min(by: Node.precedes)
return arguments.first?.nodes?.firstInDocumentOrder()
}
private static func identified(_ argument: Value?, _ context: EvaluationContext) -> [Node] {

View File

@ -37,7 +37,7 @@ public extension PureXML.XPath {
public var string: String {
switch self {
case let .nodeSet(nodes):
guard let first = nodes.min(by: Node.precedes) else { return "" }
guard let first = nodes.firstInDocumentOrder() else { return "" }
return first.stringValue
case let .boolean(value): return value ? "true" : "false"
case let .number(value): return Self.format(value)

View File

@ -118,10 +118,16 @@ public extension PureXML.XPath {
return path.reversed()
}
/// Whether `lhs` precedes `rhs` in document order.
/// Whether `lhs` precedes `rhs` in document order. Each call recomputes
/// both order keys; for sorts and minimums over node-sets, prefer
/// ``Swift/Sequence/sortedByDocumentOrder()`` and
/// ``Swift/Sequence/firstInDocumentOrder()``, which compute each key once.
static func precedes(_ lhs: Node, _ rhs: Node) -> Bool {
let left = lhs.documentOrder
let right = rhs.documentOrder
ordered(lhs.documentOrder, before: rhs.documentOrder)
}
/// Lexicographic compare of two document-order keys.
static func ordered(_ left: [Int], before right: [Int]) -> Bool {
for (one, two) in zip(left, right) where one != two {
return one < two
}
@ -129,3 +135,21 @@ public extension PureXML.XPath {
}
}
}
extension Sequence<PureXML.XPath.Node> {
/// The nodes sorted into document order, computing each node's order key once
/// (decorate-sort-undecorate) rather than recomputing the root path on every
/// comparison, which turns an O(n log n x depth) sort into O(n log n).
func sortedByDocumentOrder() -> [PureXML.XPath.Node] {
map { (node: $0, key: $0.documentOrder) }
.sorted { PureXML.XPath.Node.ordered($0.key, before: $1.key) }
.map(\.node)
}
/// The first node in document order, computing each node's order key once.
func firstInDocumentOrder() -> PureXML.XPath.Node? {
map { (node: $0, key: $0.documentOrder) }
.min { PureXML.XPath.Node.ordered($0.key, before: $1.key) }?
.node
}
}