fix(schema): bound content-matcher configuration set per counter

The counted content automaton clamped every occurrence counter to a single
inputLength + 1 ceiling, so a content model with nested occurrence bounds let
the epsilon-closure grow toward inputLength raised to the counter count. The
XSTS particlesQ005 instance (about 1000 elements against
sequence{1,1000}(e1{1,unbounded})) drove that to roughly 16M configurations per
closure and hundreds of millions of hash operations, so validation did not
terminate in reasonable time.

Each counter now saturates at min(maximum, inputLength + 1), and an unbounded
counter at its minimum: once the minimum is reached every higher count is
matching-equivalent, because canExit only tests the minimum and belowMaximum is
vacuous, so collapsing them is exact. The active-configuration set is then
bounded by the content model rather than by the input length.

particlesQ005 goes from a multi-minute near-hang to about two seconds with
identical results. The 1448 unit tests pass and the full XSTS suite still
settles at 0/43/0/31, and now completes instead of hanging.
This commit is contained in:
Mihaela Mihaljevic 2026-06-19 22:06:58 +02:00
parent 8d30010e0d
commit 73ba9b1f38
2 changed files with 38 additions and 11 deletions

View File

@ -28,6 +28,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Bounded the content-model matcher's active-configuration set per counter
(`ContentNFA`/`ContentMatcher`). Each occurrence counter now saturates at
`min(maximum, inputLength + 1)`, and an unbounded counter at its `minimum`
(every higher count is matching-equivalent, since `canExit` only tests the
minimum and `belowMaximum` is vacuous), instead of every counter sharing one
`inputLength + 1` ceiling. Validating a document with a long run of one element
against a content model with nested occurrence bounds (e.g.
`sequence{1,1000}(e1{1,unbounded})` over ~1000 elements) no longer expands the
epsilon-closure toward `inputLength` raised to the counter count: the XSTS
`particlesQ005` instance went from a multi-minute near-hang to about two
seconds, with identical validation results (the full XSTS suite still settles
at 0/43/0/31, and the suite, which previously did not terminate, now completes).
- Silenced an optional-interpolation warning in the union dedup signature
(`XSDParserUnion`): the `localName` interpolation now guards with `?? ""` to
match the `name`-attribute interpolation on the same line. The signature is an

View File

@ -34,12 +34,12 @@ extension PureXML.Schema {
case reset(Int)
case increment(Int)
func apply(to values: inout [Int], limit: Int) {
func apply(to values: inout [Int], caps: [Int]) {
switch self {
case let .reset(counter):
values[counter] = 0
case let .increment(counter):
if values[counter] < limit {
if values[counter] < caps[counter] {
values[counter] += 1
}
}
@ -133,7 +133,7 @@ extension PureXML.Schema {
/// The active set before any child: the closure of the start state.
func startStates(inputLength: Int = 0) -> Set<ContentConfiguration> {
let seed = ContentConfiguration(state: start, counters: Array(repeating: 0, count: counters.count))
return closure([seed], counterLimit: counterLimit(for: inputLength))
return closure([seed], caps: counterCaps(for: inputLength))
}
/// The labels admissible from `current`, for diagnostics and completions.
@ -188,7 +188,7 @@ extension PureXML.Schema {
} else {
result.append(.element(type: matched.elementType, valueConstraint: matched.valueConstraint))
}
current = closure(next, counterLimit: counterLimit(for: inputLength))
current = closure(next, caps: counterCaps(for: inputLength))
}
return result
}
@ -204,7 +204,7 @@ extension PureXML.Schema {
}
}
if next.isEmpty { return nil }
return closure(next, counterLimit: counterLimit(for: inputLength))
return closure(next, caps: counterCaps(for: inputLength))
}
/// Whether the content may legally end at `current`.
@ -212,12 +212,12 @@ extension PureXML.Schema {
current.contains { $0.state == accept }
}
private func closure(_ seed: Set<ContentConfiguration>, counterLimit: Int) -> Set<ContentConfiguration> {
private func closure(_ seed: Set<ContentConfiguration>, caps: [Int]) -> Set<ContentConfiguration> {
var seen = seed
var stack = Array(seed)
while let configuration = stack.popLast() {
for edge in states[configuration.state].epsilon {
guard let next = apply(edge, to: configuration, counterLimit: counterLimit),
guard let next = apply(edge, to: configuration, caps: caps),
seen.insert(next).inserted
else {
continue
@ -231,20 +231,34 @@ extension PureXML.Schema {
private func apply(
_ edge: ContentEpsilonEdge,
to configuration: ContentConfiguration,
counterLimit: Int,
caps: [Int],
) -> ContentConfiguration? {
for guardCondition in edge.guards where !guardCondition.accepts(configuration.counters) {
return nil
}
var values = configuration.counters
for action in edge.actions {
action.apply(to: &values, limit: counterLimit)
action.apply(to: &values, caps: caps)
}
return ContentConfiguration(state: edge.target, counters: values)
}
private func counterLimit(for inputLength: Int) -> Int {
inputLength == Int.max ? Int.max : inputLength + 1
/// The per-counter saturation point for the active-set representation.
///
/// An occurrence counter only needs distinct values up to what its own
/// bound and the available input make observable. A bounded counter
/// saturates at `min(maximum, inputLength + 1)`. An unbounded counter
/// saturates at its `minimum`: once the minimum is reached every higher
/// count is matching-equivalent (`canExit` stays satisfied and
/// `belowMaximum` is vacuous), so collapsing them is exact. Clamping each
/// counter independently bounds the active-configuration set by the content
/// model instead of by `inputLength` raised to the counter count, so a long
/// run of one repeated element no longer makes the closure blow up.
private func counterCaps(for inputLength: Int) -> [Int] {
let reachable = inputLength == Int.max ? Int.max : inputLength + 1
return counters.map { scope in
scope.range.maximum.clamped(to: reachable) ?? scope.range.minimum.clamped(to: reachable)
}
}
}
}