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:
parent
8d30010e0d
commit
73ba9b1f38
13
CHANGELOG.md
13
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user