perf(schema): assert the GSS matcher's runtime bound by count, not wall-clock (#369)

The interactive-safety bar wants a proven worst-case bound, not a cap or a timeout. The
graph-structured-stack matcher shares stack suffixes, so on a model with S program states
and an input of length N the live-configuration set and the GSS node count stay O(S*N)
where an explicit return stack reaches O(distinct stacks): a nullable doubling chain that
gives an explicit stack ~3^depth configurations gives the GSS O(depth).

ContentRTNMatcher now exposes peakLiveConfigurations and gssNodeCount, and the differential
asserts they stay linear in the chain depth (measured gss = K + 2, peak configs = 3K + 3),
by count rather than a machine-dependent wall-clock timeout, so a regression to super-linear
fails the test. Native-only (the depth-80 RTN build recurses on nesting depth and overflows
WASI's small stack; that builder depth-safety is tracked separately). No behaviour change.
This commit is contained in:
Mihaela Mihaljevic 2026-06-28 20:36:54 +02:00 committed by GitHub
parent 9cce695090
commit 5bd4b77305
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 36 additions and 0 deletions

View File

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The graph-structured-stack content matcher's runtime resource use now has a proven, asserted bound: `ContentRTNMatcher` exposes `peakLiveConfigurations` (the largest live-configuration set in any closure) and `gssNodeCount` (the total graph-structured-stack nodes built), and `ContentRTNDifferentialTests` asserts they stay linear in a shared-group chain's depth (measured `gss = K + 2`, peak configurations `= 3K + 3`), where an explicit return stack would reach `O(distinct stacks)`. The bound is checked by count, not by a machine-dependent wall-clock timeout, so a regression to super-linear fails the test rather than only slowing down. Test-and-metadata only; no shipped behaviour change.
- A generated-schema determinism differential against libxml2 (`DeterminismDifferentialTests`, opt-in via `DETERMINISM_DIFF` and `xmllint`) fuzzes content models, elements, group references, choices, sequences, `all` groups, occurrences, and wildcards, and compares PureXML's Unique Particle Attribution accept/reject verdict to the reference's. The XSTS corpus is finite and did not exercise the multiply-referenced-group overlap that was a real gap; this characterises the UPA check over the open shape space instead, asserting the cardinal rule (zero schemas PureXML rejects as ambiguous that libxml2 accepts) and reporting false negatives for triage. It found zero false positives and a genuine, named under-rejection: a repeating model whose optional trailing particle and leading particle share a name (`(c, b?, c?)*`), where the compositional check forms the internal gap before the enclosing repetition's loop edge, so the combined decision set is never checked. Test-only; no shipped behaviour change.
- A model `Group` now carries an internal `definitionID`, a stable identity stamped on each `<xs:group>` expansion (from the existing per-compile memo key) and shared by every reference to it. It is metadata only (`Group` is not `Equatable`/`Hashable`, so it perturbs no comparison) and has no consumer yet: it is the first step of replacing the per-instance content-model build's per-reference group inlining (the `2^K` doubling-chain hang) with the graph-structured-stack matcher designed in `docs/design/counted-content-automaton.md`. No behaviour change; the XSTS buckets are unchanged.

View File

@ -49,6 +49,16 @@ extension PureXML.Schema {
/// closure; drained into the worklist.
private var pending: [ContentRTNConfiguration] = []
/// The matcher's observable resource bound for the last match: the largest live
/// configuration set across any single closure, and the total graph-structured
/// stack nodes built. The GSS shares stack suffixes, so on a model with `S` program
/// states and an input of length `N` both stay `O(S * N)` rather than the
/// `O(distinct-return-stacks)` an explicit stack grows to (the doubling chain that
/// gives an explicit stack ~3^depth configurations gives the GSS `O(depth)`).
/// Exposed so a test can assert the bound by counts, not by wall-clock.
private(set) var peakLiveConfigurations = 0
private(set) var gssNodeCount = 0
init(_ rtn: ContentRTN) {
self.rtn = rtn
}
@ -170,6 +180,8 @@ extension PureXML.Schema {
intern = [:]
position = 0
caps = counterCaps(for: inputLength)
peakLiveConfigurations = 0
gssNodeCount = 0
}
private func closure(_ seed: Set<ContentRTNConfiguration>) -> Set<ContentRTNConfiguration> {
@ -199,6 +211,8 @@ extension PureXML.Schema {
drainPending(into: &seen, work: &work)
}
}
peakLiveConfigurations = max(peakLiveConfigurations, seen.count)
gssNodeCount = nodes.count
return seen
}

View File

@ -163,6 +163,26 @@ struct ContentRTNDifferentialTests {
#expect(rtn.states.count < 24 * 20, "shared build is not linear in depth: \(rtn.states.count) states")
}
// The matcher's runtime resource use, asserted by COUNT not wall-clock (a timeout is
// machine-dependent): on a depth-K shared-group chain the live-configuration set and
// the graph-structured-stack node count stay linear in K (measured gss = K + 2, peak
// configurations = 3K + 3), where an explicit return stack would reach O(distinct
// stacks). A generous linear ceiling separates it from any super-linear regression.
// Native-only: the depth-80 RTN build recurses on nesting depth and overflows WASI's
// small single-threaded stack (the builder's deeper WASI depth-safety is separate).
#if !os(WASI)
@Test("the matcher's runtime resource use is linear in the depth, not exponential")
func test_runtimeBoundIsLinear() {
for depth in [20, 80] {
let rtn = PureXML.Schema.ContentRTNBuilder.build(doubling(depth: depth))
let matcher = PureXML.Schema.ContentRTNMatcher(rtn)
_ = matcher.matchesWhole(names(["x", "x", "x"]))
#expect(matcher.gssNodeCount < depth + 16, "GSS nodes not linear at depth \(depth): \(matcher.gssNodeCount)")
#expect(matcher.peakLiveConfigurations < 3 * depth + 16, "live configs not linear at depth \(depth): \(matcher.peakLiveConfigurations)")
}
}
#endif
@Test("agree on a fuzz of nested models with occurrences and shared groups")
func test_fuzz() {
var rng = SeededRNG(seed: 0x5EED_1234)