PureXML/Sources/XPath/SiblingIndexCache.swift
Mihaela Mihaljevic 9e345ea0fe feat(xpath,parsing): public streaming surface, linear document-order at scale
EventReader.next(), both initializers, and the Event alias are public
(an internal typealias in a split file had been shadowing the public
Parsing.Event); a new PureXMLPublicAPITests target compiles without
testable import so facade gaps fail CI. The large-corpus benchmarks
exposed document-order sorting as quadratic on flat fan-outs; the fix's
first attempt was itself worse (an eager sibling table built per
predicate evaluation), caught by sampling the live run, and replaced
with a lazy table: single-node sets never touch order keys and a
parent's table builds on its second lookup. XPath at 42 MB drops
27.1 s to 1.57 s (190x to 11.7x); at 210 MB PureXML completes in 9.4 s
the query libxml2 refuses at its 10M node-set cap (#143 tracks our
configurable budget). Benchmark harness: size series to 210 MB, CORPUS
mode for real files (NASA, SwissProt measured), SAX stage,
refusal-aware table, NULL-safe C driver. All conformance suites,
Linux, and WASM verified. Closes #142.
2026-06-11 06:06:31 +02:00

35 lines
1.7 KiB
Swift

extension PureXML.XPath {
/// A per-operation cache of each node's index among its parent's
/// children. Document-order keys need the index at every ancestor level;
/// without a cache each lookup scans the sibling list, so sorting a
/// node-set drawn from one flat fan-out is quadratic (the 42 MB
/// benchmark corpus spent 27 seconds there). Each distinct parent is
/// enumerated exactly once.
final class SiblingIndexCache {
private var indexes: [ObjectIdentifier: [ObjectIdentifier: Int]] = [:]
private var scannedOnce: Set<ObjectIdentifier> = []
func index(of child: PureXML.Model.TreeNode, in parent: PureXML.Model.TreeNode) -> Int {
let parentIdentity = ObjectIdentifier(parent)
if let table = indexes[parentIdentity] {
return table[ObjectIdentifier(child)] ?? 0
}
// The table only pays for itself from the second lookup under
// the same parent: a single lookup (one key over a wide parent,
// the per-predicate case that once built a million-entry table
// per evaluation) scans linearly instead.
guard scannedOnce.contains(parentIdentity) else {
scannedOnce.insert(parentIdentity)
return parent.children.firstIndex { $0 === child } ?? 0
}
var table: [ObjectIdentifier: Int] = [:]
table.reserveCapacity(parent.children.count)
for (index, node) in parent.children.enumerated() {
table[ObjectIdentifier(node)] = index
}
indexes[parentIdentity] = table
return table[ObjectIdentifier(child)] ?? 0
}
}
}