From b8dbf8e97ad89d5336c1cc551cdbafe386b277e3 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 03:39:19 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + Sources/XPath/EXSLTFunctions.swift | 19 ++++++++++++------- Sources/XPath/Evaluator.swift | 2 +- Sources/XPath/NodeFunctions.swift | 2 +- Sources/XPath/Value.swift | 2 +- Sources/XPath/XPathNode.swift | 30 +++++++++++++++++++++++++++--- 6 files changed, 43 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d59e589..aae4d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` values.) diff --git a/Sources/XPath/EXSLTFunctions.swift b/Sources/XPath/EXSLTFunctions.swift index 7aa6337..c95e060 100644 --- a/Sources/XPath/EXSLTFunctions.swift +++ b/Sources/XPath/EXSLTFunctions.swift @@ -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 = [] 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) } } } diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index e065535..ca64659 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -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 { diff --git a/Sources/XPath/NodeFunctions.swift b/Sources/XPath/NodeFunctions.swift index c7d4e1a..c17afe0 100644 --- a/Sources/XPath/NodeFunctions.swift +++ b/Sources/XPath/NodeFunctions.swift @@ -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] { diff --git a/Sources/XPath/Value.swift b/Sources/XPath/Value.swift index 30e061b..e25ffc0 100644 --- a/Sources/XPath/Value.swift +++ b/Sources/XPath/Value.swift @@ -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) diff --git a/Sources/XPath/XPathNode.swift b/Sources/XPath/XPathNode.swift index ff0ff41..dc5605f 100644 --- a/Sources/XPath/XPathNode.swift +++ b/Sources/XPath/XPathNode.swift @@ -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 { + /// 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 + } +}