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)