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.
35 lines
1.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|