diff --git a/CHANGELOG.md b/CHANGELOG.md index afd1b6f..e9bd938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A single forward-axis location step from one context node no longer sorts its result into document order, because it is already in document order with no duplicates. `AxisNavigation` produces the structural forward axes (`child`, `descendant`, `descendant-or-self`, `self`, `following`, `following-sibling`) in document order, deriving sibling order from the same `children` array the order key compares, so a one-step path over any of them needs neither the document-order sort nor de-duplication. (This is the common shape after `//x` compiles and fuses to a single `descendant::x` step.) The attribute axis (whose order interleaves with namespace declarations) and the namespace axis (produced in dictionary/hash order), plus every reverse axis (nearest-first), are excluded and still sorted. Measured `xpath` ~7% faster on the 20k-item generated corpus via a same-machine-state A/B, identical results. Guarded by `XPathSingleStepOrderTests` (document order for each included axis, reverse axes still normalized, multi-step paths still sorted) and confirmed by an adversarial order-correctness review that found no query whose order or uniqueness the change breaks. + - A path expression's result is no longer re-de-duplicated before sorting. `evaluateSteps` already returns a duplicate-free node-set (each step de-duplicates across its context nodes, or uses an axis whose per-context results cannot overlap), so the `Set`-based de-duplication that ran before the document-order sort copied every node for nothing; each copy retains the wrapped tree node, whose `weak parent` reference forces a side table and the slow reference-counting path. Path and trailing-path evaluation now sort the steps' result directly; unions and filter primaries (which genuinely can carry duplicates) keep the de-duplicating pass. Measured `xpath` ~9% faster on the 20k-item generated corpus via a same-machine-state A/B, identical results. Covered by the existing node-set de-duplication and union tests (a path still yields each node once). - XPath compiles `//` (the abbreviation for `descendant-or-self::node()/child::X`) to a single `descendant::X` step when the child step's predicates are non-positional, avoiding the materialization of every node in the subtree as an intermediate context (measured `xpath` ~10x -> ~7x slower than libxml2 on the 20k-item generated corpus via a same-machine-state A/B, identical results; ~54% faster than before this cycle's two XPath changes combined). The two node-sets are equal, so the fusion is transparent, EXCEPT for a positional predicate: `//x[1]` is the first `x` child of EACH parent, not the first `x` in document order, which `descendant::x[1]` would wrongly select. The rewrite is therefore applied only when every predicate is provably non-positional: its value is a boolean, node-set, or string (never a number, which XPath reads as `position() = value`) and it nowhere references `position()` or `last()`. Anything not provably both is left unfused, so a result can never change. Guarded by `XPathDescendantFusionTests` (positional forms keep their per-parent meaning, non-positional forms select across the subtree, composition with trailing and nested steps) and confirmed by an adversarial differential review (fused versus a spec-literal unfused reference) that found no query whose result the fusion changes. diff --git a/Sources/XPath/Axis.swift b/Sources/XPath/Axis.swift index d4ed5d2..d1e7bf3 100644 --- a/Sources/XPath/Axis.swift +++ b/Sources/XPath/Axis.swift @@ -27,6 +27,22 @@ extension PureXML.XPath { } } + /// Whether the axis, taken from a SINGLE context node, yields its nodes + /// already in document order with no duplicates, so a one-step path over + /// it needs neither the de-duplication nor the document-order sort. True + /// for the structural forward axes, which `AxisNavigation` produces in + /// document order (child by child-array order, descendant by pre-order, + /// the following families by document position). The attribute axis is + /// excluded because its order interleaves with namespace declarations, + /// and the namespace axis because its nodes are produced in dictionary + /// (hash) order; the sort normalizes both, so they are left to it. + var preservesDocumentOrderFromSingleContext: Bool { + switch self { + case .child, .descendant, .descendantOrSelf, .selfAxis, .followingSibling, .following: true + default: false + } + } + /// Whether the axis yields disjoint node-sets from distinct context nodes, /// so accumulating its results across a step's context nodes can never /// produce a duplicate and needs no cross-context de-duplication. A child, diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index 88420cb..0840f5f 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -165,12 +165,18 @@ extension PureXML.XPath { private static func evalPath(absolute: Bool, steps: [Step], _ context: EvaluationContext) throws -> Value { let start: Node = absolute ? root(of: context.node) : context.node - // `evaluateSteps` already yields a duplicate-free node-set (each step - // either de-duplicates across contexts or uses an axis that cannot - // produce a duplicate), so only the document-order sort remains; the - // `Set` de-duplication in `orderUnique` would copy every node for - // nothing. - return try .nodeSet(evaluateSteps(steps, from: [start], context).sortedByDocumentOrder()) + let nodes = try evaluateSteps(steps, from: [start], context) + // A single forward-axis step from the one start node yields nodes + // already in document order with no duplicates (the axis produces + // document order and cannot repeat a node from one context, and the + // node test and predicates only filter), so the sort is redundant. + // Otherwise sort: `evaluateSteps` is already duplicate-free (each step + // de-duplicates or uses a per-context-disjoint axis), so only the + // document-order sort is needed, not the `Set` de-duplication. + if steps.count == 1, steps[0].axis.preservesDocumentOrderFromSingleContext { + return .nodeSet(nodes) + } + return .nodeSet(nodes.sortedByDocumentOrder()) } private static func evalFilter( diff --git a/Tests/XPathSingleStepOrderTests.swift b/Tests/XPathSingleStepOrderTests.swift new file mode 100644 index 0000000..8d06fe3 --- /dev/null +++ b/Tests/XPathSingleStepOrderTests.swift @@ -0,0 +1,73 @@ +import Testing +@testable import PureXML + +/// A single forward-axis step from one context node is returned without the +/// document-order sort, on the proof that `AxisNavigation` already produces the +/// structural forward axes in document order. These tests pin that the order is +/// in fact document order for each such axis (so the skipped sort would not have +/// changed it), and that the axes left to the sort (attribute, namespace) and +/// the reverse axes still order correctly. +@Suite("XPath single-step path order") +struct XPathSingleStepOrderTests { + /// Document order of the `n` elements is 1..6; b and c are nested so a naive + /// per-parent accumulation would differ from document order. + private static let source = """ + + + + + + + """ + + private func ids(_ path: String) throws -> [String] { + try PureXML.XPath.Query(path).evaluate(over: PureXML.parse(Self.source)).compactMap { + if case let .node(node) = $0 { return node.element?.attributes.first { $0.name.localName == "id" }?.value } + return nil + } + } + + private func names(_ path: String) throws -> [String] { + try PureXML.XPath.Query(path).evaluate(over: PureXML.parse(Self.source)).compactMap { + if case let .node(node) = $0 { return node.element?.name.localName } + return nil + } + } + + @Test("descendant axis returns in document order without the sort") + func test_descendantOrder() throws { + #expect(try ids("//n") == ["1", "2", "3", "4", "5", "6"]) + #expect(try ids("/r/descendant::n") == ["1", "2", "3", "4", "5", "6"]) + } + + @Test("child and descendant-or-self single steps are in document order") + func test_childAndSelfOrder() throws { + // The n children of r are n1 and n5 (the a elements have no id). + #expect(try ids("/r/child::n") == ["1", "5"]) + // /r/a selects BOTH a elements; descendant-or-self::*[@id] under them is + // n2, n3, n4 (first a) and n6 (second a), sorted to document order. + #expect(try ids("/r/a/descendant-or-self::*[@id]") == ["2", "3", "4", "6"]) + } + + @Test("following and following-sibling single steps are in document order") + func test_followingOrder() throws { + // following:: from n2 reaches n3, n4, n5, n6 (not n2's ancestors/self). + #expect(try ids("//n[@id='2']/following::n") == ["3", "4", "5", "6"]) + // following-sibling:: of n2 within its parent a: b(no id) then n4. + #expect(try ids("//n[@id='2']/following-sibling::n") == ["4"]) + } + + @Test("reverse axes still order nearest-first via the sort path") + func test_reverseAxisOrder() throws { + // Ancestors of n3 are b, a, r; the node-set normalizes to document order. + #expect(try names("//n[@id='3']/ancestor::*") == ["r", "a", "b"]) + // preceding:: of n5 excludes ancestors; n1 and the first a's n2/n3/n4. + #expect(try ids("//n[@id='5']/preceding::n") == ["1", "2", "3", "4"]) + } + + @Test("a multi-step path is still sorted to document order") + func test_multiStepStillSorted() throws { + // //a/n gathers n children of every a; document order across both a's. + #expect(try ids("//a/n") == ["2", "4", "6"]) + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 3aa3850..d893b84 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -63,6 +63,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 9 | skip cross-context node-set de-duplication on a single context or a disjoint axis (child/attribute/namespace/self); the `Set` hashing and copying was pure overhead where no duplicate can arise, same output (same-state A/B, ~28%) | unchanged | 0.097 s (10x, from 13x) | unchanged | | 10 | `//` descendant fusion (`descendant-or-self::node()/child::X[P]` compiled to `descendant::X[P]` when `P` is non-positional), avoiding the whole-subtree intermediate context; guarded so positional predicates keep their per-parent meaning, adversarially reviewed for semantic transparency | unchanged | 0.063 s (~7x, from 10x) | unchanged | | 11 | drop the redundant `Set` de-duplication when sorting a path result: `evaluateSteps` already yields a duplicate-free node-set, so only the document-order sort is needed (each skipped insert was a copy of a node wrapping a side-table-refcounted tree node); same-state A/B, ~9% | unchanged | 0.058 s (~6x, from ~7x) | unchanged | +| 12 | skip the document-order sort for a single forward-axis step from one context: `AxisNavigation` produces child/descendant/descendant-or-self/self/following/following-sibling already in document order with no duplicates, so the sort is redundant (adversarially reviewed for order correctness; same-state A/B, ~7%) | unchanged | 0.055 s (~6x) | unchanged | ## Profile findings (first sample, parse path)