fix(schema): count content-model occurrences
This commit is contained in:
parent
47c3a230e4
commit
b73384bc2e
@ -28,6 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Replaced XSD content-model occurrence unrolling with a counted automaton.
|
||||
`ContentMatcher` now tracks occurrence counters in active configurations instead
|
||||
of copying particle bodies into states, preserving exact finite `{m,n}` behavior
|
||||
for high numeric bounds and removing the instance-time silent widening at the old
|
||||
state ceilings. XSD occurrence attributes are preserved as decimal magnitudes
|
||||
beyond `Int` range so `maxOccurs="100000000000000000000"` no longer falls back
|
||||
to the default `1`.
|
||||
|
||||
- XSTS invalid-schemas-accepted (#145): global element references now carry the
|
||||
referenced declaration's type name, fixed/default constraint, nillable flag,
|
||||
and `block` metadata into Particle-Valid-Restriction. A local element with an
|
||||
|
||||
@ -179,13 +179,26 @@ public extension PureXML.Schema {
|
||||
/// A particle: a term with an occurrence range. `maxOccurs` nil means
|
||||
/// unbounded.
|
||||
struct Particle: Sendable {
|
||||
public var minOccurs: Int
|
||||
public var maxOccurs: Int?
|
||||
public var occurrenceRange: OccurrenceRange
|
||||
public var term: Term
|
||||
|
||||
public var minOccurs: Int {
|
||||
get { occurrenceRange.minimum.clamped(to: Int.max) }
|
||||
set { occurrenceRange.minimum = NonNegativeDecimal(newValue) }
|
||||
}
|
||||
|
||||
public var maxOccurs: Int? {
|
||||
get { occurrenceRange.maximum.clamped(to: Int.max) }
|
||||
set { occurrenceRange.maximum = OccurrenceUpper(newValue) }
|
||||
}
|
||||
|
||||
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term) {
|
||||
self.minOccurs = minOccurs
|
||||
self.maxOccurs = maxOccurs
|
||||
occurrenceRange = OccurrenceRange(minimum: minOccurs, maximum: maxOccurs)
|
||||
self.term = term
|
||||
}
|
||||
|
||||
public init(occurrenceRange: OccurrenceRange, term: Term) {
|
||||
self.occurrenceRange = occurrenceRange
|
||||
self.term = term
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,9 +9,10 @@ extension PureXML.Schema.ComplexValidator {
|
||||
let steps = Self.childSteps(children)
|
||||
// Advance one active state-set across the children rather than re-walking
|
||||
// the prefix per child (which is quadratic over the content model, #129).
|
||||
var current = nfa.startStates()
|
||||
let inputLength = children.count
|
||||
var current = nfa.startStates(inputLength: inputLength)
|
||||
for (index, child) in children.enumerated() {
|
||||
guard let next = nfa.step(current, over: child.name) else {
|
||||
guard let next = nfa.step(current, over: child.name, inputLength: inputLength) else {
|
||||
let allowed = nfa.admissible(from: current)
|
||||
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here\(Self.expectation(allowed))", at: path + [steps[index]]))
|
||||
return
|
||||
|
||||
@ -14,9 +14,54 @@ extension PureXML.Schema {
|
||||
}
|
||||
}
|
||||
|
||||
/// One state of the content-model automaton.
|
||||
/// A guard on a counted epsilon transition.
|
||||
enum ContentCounterGuard: Sendable {
|
||||
case canExit(counter: Int, minimum: NonNegativeDecimal, nullableBody: Bool)
|
||||
case belowMaximum(counter: Int, maximum: OccurrenceUpper)
|
||||
|
||||
func accepts(_ values: [Int]) -> Bool {
|
||||
switch self {
|
||||
case let .canExit(counter, minimum, nullableBody):
|
||||
nullableBody || minimum.isLessThanOrEqual(to: values[counter])
|
||||
case let .belowMaximum(counter, maximum):
|
||||
maximum.isGreaterThan(values[counter])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An action on a counted epsilon transition.
|
||||
enum ContentCounterAction: Sendable {
|
||||
case reset(Int)
|
||||
case increment(Int)
|
||||
|
||||
func apply(to values: inout [Int], limit: Int) {
|
||||
switch self {
|
||||
case let .reset(counter):
|
||||
values[counter] = 0
|
||||
case let .increment(counter):
|
||||
if values[counter] < limit {
|
||||
values[counter] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One epsilon transition in the counted content-model automaton.
|
||||
struct ContentEpsilonEdge: Sendable {
|
||||
var target: Int
|
||||
var guards: [ContentCounterGuard] = []
|
||||
var actions: [ContentCounterAction] = []
|
||||
}
|
||||
|
||||
/// One occurrence counter in the counted content-model automaton.
|
||||
struct ContentCounterScope: Sendable {
|
||||
var range: OccurrenceRange
|
||||
var nullableBody: Bool
|
||||
}
|
||||
|
||||
/// One state of the counted content-model automaton.
|
||||
struct ContentState: Sendable {
|
||||
var epsilon: [Int] = []
|
||||
var epsilon: [ContentEpsilonEdge] = []
|
||||
var label: TermLabel?
|
||||
var target: Int?
|
||||
/// The declared type and value constraint of the element particle this
|
||||
@ -36,17 +81,26 @@ extension PureXML.Schema {
|
||||
case wildcard(Wildcard)
|
||||
}
|
||||
|
||||
/// A Thompson NFA over element names compiled from a ``Particle``. `all` groups
|
||||
/// are validated separately by ``ContentMatcher`` and are not represented here.
|
||||
/// One active configuration in the counted content-model automaton.
|
||||
struct ContentConfiguration: Sendable, Hashable {
|
||||
var state: Int
|
||||
var counters: [Int]
|
||||
}
|
||||
|
||||
/// A counted automaton over element names compiled from a ``Particle``. `all`
|
||||
/// groups are validated separately by ``ContentMatcher`` and are not represented
|
||||
/// here.
|
||||
struct ContentNFA: Sendable {
|
||||
var states: [ContentState]
|
||||
var counters: [ContentCounterScope]
|
||||
var start: Int
|
||||
var accept: Int
|
||||
|
||||
func matchesWhole(_ names: [PureXML.Model.QualifiedName]) -> Bool {
|
||||
var current = startStates()
|
||||
let inputLength = names.count
|
||||
var current = startStates(inputLength: inputLength)
|
||||
for name in names {
|
||||
guard let next = step(current, over: name) else { return false }
|
||||
guard let next = step(current, over: name, inputLength: inputLength) else { return false }
|
||||
current = next
|
||||
}
|
||||
return isComplete(current)
|
||||
@ -58,9 +112,10 @@ extension PureXML.Schema {
|
||||
/// whether something is still required. Returns an empty, not-complete
|
||||
/// result when the prefix is already invalid.
|
||||
func follow(after names: [PureXML.Model.QualifiedName]) -> (allowed: [TermLabel], complete: Bool) {
|
||||
var current = startStates()
|
||||
let inputLength = names.count
|
||||
var current = startStates(inputLength: inputLength)
|
||||
for name in names {
|
||||
guard let next = step(current, over: name) else { return ([], false) }
|
||||
guard let next = step(current, over: name, inputLength: inputLength) else { return ([], false) }
|
||||
current = next
|
||||
}
|
||||
return (admissible(from: current), isComplete(current))
|
||||
@ -69,22 +124,23 @@ extension PureXML.Schema {
|
||||
// MARK: Incremental matching
|
||||
|
||||
//
|
||||
// The active set is the epsilon-closure of the states reachable after some
|
||||
// The active set is the epsilon-closure of the configurations reachable after some
|
||||
// prefix of children. Callers that scan a child sequence advance it one
|
||||
// child at a time (`startStates` then `step` per child) rather than calling
|
||||
// `follow(after:)` on a growing prefix, which re-walks the whole prefix each
|
||||
// time and is quadratic in the child count over a content model (#129).
|
||||
|
||||
/// The active set before any child: the closure of the start state.
|
||||
func startStates() -> Set<Int> {
|
||||
closure([start])
|
||||
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))
|
||||
}
|
||||
|
||||
/// The labels admissible from `current`, for diagnostics and completions.
|
||||
func admissible(from current: Set<Int>) -> [TermLabel] {
|
||||
func admissible(from current: Set<ContentConfiguration>) -> [TermLabel] {
|
||||
var labels: [TermLabel] = []
|
||||
for state in current where states[state].target != nil {
|
||||
if let label = states[state].label { labels.append(label) }
|
||||
for configuration in current where states[configuration.state].target != nil {
|
||||
if let label = states[configuration.state].label { labels.append(label) }
|
||||
}
|
||||
return labels
|
||||
}
|
||||
@ -100,207 +156,95 @@ extension PureXML.Schema {
|
||||
/// reproducible rather than left to `Set` iteration order: a named particle
|
||||
/// is preferred over a wildcard, then the lowest state index wins.
|
||||
func matchedParticles(_ names: [PureXML.Model.QualifiedName]) -> [MatchedParticle?] {
|
||||
var current = startStates()
|
||||
let inputLength = names.count
|
||||
var current = startStates(inputLength: inputLength)
|
||||
var result: [MatchedParticle?] = []
|
||||
for name in names {
|
||||
var next: Set<Int> = []
|
||||
var namedMatch: Int?
|
||||
var anyMatch: Int?
|
||||
for state in current {
|
||||
var next: Set<ContentConfiguration> = []
|
||||
var namedMatch: ContentConfiguration?
|
||||
var namedState: Int?
|
||||
var anyMatch: ContentConfiguration?
|
||||
var anyState: Int?
|
||||
for configuration in current {
|
||||
let state = configuration.state
|
||||
guard let label = states[state].label, let target = states[state].target, label.matches(name) else { continue }
|
||||
next.insert(target)
|
||||
if anyMatch.map({ state < $0 }) ?? true { anyMatch = state }
|
||||
if case .name = label, namedMatch.map({ state < $0 }) ?? true { namedMatch = state }
|
||||
next.insert(ContentConfiguration(state: target, counters: configuration.counters))
|
||||
if anyState.map({ state < $0 }) ?? true {
|
||||
anyState = state
|
||||
anyMatch = configuration
|
||||
}
|
||||
if case .name = label, namedState.map({ state < $0 }) ?? true {
|
||||
namedState = state
|
||||
namedMatch = configuration
|
||||
}
|
||||
}
|
||||
guard let chosen = namedMatch ?? anyMatch, !next.isEmpty else {
|
||||
guard let chosenState = namedState ?? anyState, (namedMatch ?? anyMatch) != nil, !next.isEmpty else {
|
||||
result.append(contentsOf: Array(repeating: nil, count: names.count - result.count))
|
||||
return result
|
||||
}
|
||||
let matched = states[chosen]
|
||||
let matched = states[chosenState]
|
||||
if case let .wildcard(wildcard) = matched.label {
|
||||
result.append(.wildcard(wildcard))
|
||||
} else {
|
||||
result.append(.element(type: matched.elementType, valueConstraint: matched.valueConstraint))
|
||||
}
|
||||
current = closure(next)
|
||||
current = closure(next, counterLimit: counterLimit(for: inputLength))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Advances `current` by consuming one element `name`, or nil when `name`
|
||||
/// is not admissible there.
|
||||
func step(_ current: Set<Int>, over name: PureXML.Model.QualifiedName) -> Set<Int>? {
|
||||
var next: Set<Int> = []
|
||||
for state in current {
|
||||
func step(_ current: Set<ContentConfiguration>, over name: PureXML.Model.QualifiedName, inputLength: Int = 0) -> Set<ContentConfiguration>? {
|
||||
var next: Set<ContentConfiguration> = []
|
||||
for configuration in current {
|
||||
let state = configuration.state
|
||||
if let label = states[state].label, let target = states[state].target, label.matches(name) {
|
||||
next.insert(target)
|
||||
next.insert(ContentConfiguration(state: target, counters: configuration.counters))
|
||||
}
|
||||
}
|
||||
if next.isEmpty { return nil }
|
||||
return closure(next)
|
||||
return closure(next, counterLimit: counterLimit(for: inputLength))
|
||||
}
|
||||
|
||||
/// Whether the content may legally end at `current`.
|
||||
func isComplete(_ current: Set<Int>) -> Bool {
|
||||
current.contains(accept)
|
||||
func isComplete(_ current: Set<ContentConfiguration>) -> Bool {
|
||||
current.contains { $0.state == accept }
|
||||
}
|
||||
|
||||
private func closure(_ seed: Set<Int>) -> Set<Int> {
|
||||
private func closure(_ seed: Set<ContentConfiguration>, counterLimit: Int) -> Set<ContentConfiguration> {
|
||||
var seen = seed
|
||||
var stack = Array(seed)
|
||||
while let state = stack.popLast() {
|
||||
for next in states[state].epsilon where seen.insert(next).inserted {
|
||||
while let configuration = stack.popLast() {
|
||||
for edge in states[configuration.state].epsilon {
|
||||
guard let next = apply(edge, to: configuration, counterLimit: counterLimit),
|
||||
seen.insert(next).inserted
|
||||
else {
|
||||
continue
|
||||
}
|
||||
stack.append(next)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a ``ContentNFA`` from a particle by Thompson construction.
|
||||
struct ContentNFABuilder {
|
||||
private var states: [ContentState] = []
|
||||
|
||||
static func build(_ particle: PureXML.Schema.Particle) -> ContentNFA {
|
||||
var builder = ContentNFABuilder()
|
||||
let fragment = builder.particle(particle)
|
||||
return ContentNFA(states: builder.states, start: fragment.start, accept: fragment.accept)
|
||||
}
|
||||
|
||||
private mutating func addState() -> Int {
|
||||
states.append(ContentState())
|
||||
return states.count - 1
|
||||
}
|
||||
|
||||
/// Occurrence counts unroll into automaton states, so they are
|
||||
/// capped: beyond the cap a repetition is treated as unbounded, the
|
||||
/// libxml2 posture (its cap is 16384). Without this, a schema with
|
||||
/// maxOccurs in the trillions allocates until the process dies
|
||||
/// (found by XSTS groupF009v, #129).
|
||||
private static let occursUnrollCap = 16384
|
||||
|
||||
/// The per-particle cap bounds one repetition, but nested particles
|
||||
/// multiply: a sequence of 16384 holding a choice of 16384 is 2.7e8
|
||||
/// states even though each particle is individually capped. So the
|
||||
/// total state count is also capped; once an NFA reaches the ceiling
|
||||
/// every further repetition degrades to star, the same posture the
|
||||
/// per-particle cap already takes. Legitimate content models are tens
|
||||
/// of states, so only the pathological XSTS particle schemas (the
|
||||
/// msMeta Particles set, which OOM-killed the suite at 8 GB, #129)
|
||||
/// reach it. The ceiling sits well above a single maxed particle
|
||||
/// (~33k states) so capped-but-not-nested schemas are unaffected.
|
||||
private static let totalStateCap = 1 << 20
|
||||
|
||||
private var stateBudgetExhausted: Bool {
|
||||
states.count >= Self.totalStateCap
|
||||
}
|
||||
|
||||
private mutating func particle(_ particle: PureXML.Schema.Particle) -> (start: Int, accept: Int) {
|
||||
let start = addState()
|
||||
var current = start
|
||||
let boundedMin = Swift.min(particle.minOccurs, Self.occursUnrollCap)
|
||||
var unrolledMin = 0
|
||||
for _ in 0 ..< boundedMin {
|
||||
if stateBudgetExhausted { break }
|
||||
let part = term(particle.term)
|
||||
states[current].epsilon.append(part.start)
|
||||
current = part.accept
|
||||
unrolledMin += 1
|
||||
private func apply(
|
||||
_ edge: ContentEpsilonEdge,
|
||||
to configuration: ContentConfiguration,
|
||||
counterLimit: Int,
|
||||
) -> ContentConfiguration? {
|
||||
for guardCondition in edge.guards where !guardCondition.accepts(configuration.counters) {
|
||||
return nil
|
||||
}
|
||||
let accept = addState()
|
||||
if stateBudgetExhausted || unrolledMin < boundedMin || particle.minOccurs > Self.occursUnrollCap {
|
||||
// The tail of an absurd minOccurs, or a run that hit the total
|
||||
// state ceiling mid-unroll, is approximated as star.
|
||||
appendStar(particle.term, from: current, to: accept)
|
||||
} else if let maximum = particle.maxOccurs, maximum - boundedMin <= Self.occursUnrollCap {
|
||||
appendOptional(particle.term, count: maximum - boundedMin, from: current, to: accept)
|
||||
} else {
|
||||
appendStar(particle.term, from: current, to: accept)
|
||||
var values = configuration.counters
|
||||
for action in edge.actions {
|
||||
action.apply(to: &values, limit: counterLimit)
|
||||
}
|
||||
return (start, accept)
|
||||
return ContentConfiguration(state: edge.target, counters: values)
|
||||
}
|
||||
|
||||
private mutating func appendOptional(_ term: PureXML.Schema.Term, count: Int, from: Int, to accept: Int) {
|
||||
var current = from
|
||||
for _ in 0 ..< Swift.max(0, count) {
|
||||
if stateBudgetExhausted {
|
||||
// Ran out of state budget: let the remainder repeat freely
|
||||
// (star) rather than keep allocating bounded optionals.
|
||||
appendStar(term, from: current, to: accept)
|
||||
return
|
||||
}
|
||||
let part = self.term(term)
|
||||
states[current].epsilon.append(part.start)
|
||||
states[current].epsilon.append(accept)
|
||||
current = part.accept
|
||||
}
|
||||
states[current].epsilon.append(accept)
|
||||
}
|
||||
|
||||
private mutating func appendStar(_ term: PureXML.Schema.Term, from current: Int, to accept: Int) {
|
||||
let part = self.term(term)
|
||||
states[current].epsilon.append(part.start)
|
||||
states[current].epsilon.append(accept)
|
||||
states[part.accept].epsilon.append(part.start)
|
||||
states[part.accept].epsilon.append(accept)
|
||||
}
|
||||
|
||||
private mutating func term(_ term: PureXML.Schema.Term) -> (start: Int, accept: Int) {
|
||||
switch term {
|
||||
case let .element(name, type, _, constraint, _, _):
|
||||
let fragment = labeled(.name(name))
|
||||
states[fragment.start].elementType = type
|
||||
states[fragment.start].valueConstraint = constraint
|
||||
return fragment
|
||||
case let .wildcard(wildcard):
|
||||
return labeled(.wildcard(wildcard))
|
||||
case let .group(group):
|
||||
return self.group(group)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func labeled(_ label: TermLabel) -> (start: Int, accept: Int) {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
states[start].label = label
|
||||
states[start].target = accept
|
||||
return (start, accept)
|
||||
}
|
||||
|
||||
private mutating func group(_ group: PureXML.Schema.Group) -> (start: Int, accept: Int) {
|
||||
// `all` is validated by counting, not in the NFA; treat it as a
|
||||
// sequence here so a nested occurrence still parses.
|
||||
group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
|
||||
}
|
||||
|
||||
private mutating func sequence(_ particles: [PureXML.Schema.Particle]) -> (start: Int, accept: Int) {
|
||||
guard !particles.isEmpty else { return labeledEmpty() }
|
||||
var first: Int?
|
||||
var last: Int?
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
if let previous = last { states[previous].epsilon.append(part.start) } else { first = part.start }
|
||||
last = part.accept
|
||||
}
|
||||
return (first ?? addState(), last ?? addState())
|
||||
}
|
||||
|
||||
private mutating func choice(_ particles: [PureXML.Schema.Particle]) -> (start: Int, accept: Int) {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
states[start].epsilon.append(part.start)
|
||||
states[part.accept].epsilon.append(accept)
|
||||
}
|
||||
return (start, accept)
|
||||
}
|
||||
|
||||
private mutating func labeledEmpty() -> (start: Int, accept: Int) {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
states[start].epsilon.append(accept)
|
||||
return (start, accept)
|
||||
private func counterLimit(for inputLength: Int) -> Int {
|
||||
inputLength == Int.max ? Int.max : inputLength + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
169
Sources/Schema/ContentNFABuilder.swift
Normal file
169
Sources/Schema/ContentNFABuilder.swift
Normal file
@ -0,0 +1,169 @@
|
||||
extension PureXML.Schema {
|
||||
struct ContentNFAFragment {
|
||||
var start: Int
|
||||
var accept: Int
|
||||
var counters: [Int]
|
||||
var nullable: Bool
|
||||
}
|
||||
|
||||
/// Builds a counted ``ContentNFA`` from a particle.
|
||||
struct ContentNFABuilder {
|
||||
private var states: [ContentState] = []
|
||||
private var counters: [ContentCounterScope] = []
|
||||
|
||||
static func build(_ particle: PureXML.Schema.Particle) -> ContentNFA {
|
||||
var builder = ContentNFABuilder()
|
||||
let fragment = builder.particle(particle)
|
||||
return ContentNFA(
|
||||
states: builder.states,
|
||||
counters: builder.counters,
|
||||
start: fragment.start,
|
||||
accept: fragment.accept,
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func addState() -> Int {
|
||||
states.append(ContentState())
|
||||
return states.count - 1
|
||||
}
|
||||
|
||||
private mutating func addEpsilon(
|
||||
from source: Int,
|
||||
to target: Int,
|
||||
guards: [ContentCounterGuard] = [],
|
||||
actions: [ContentCounterAction] = [],
|
||||
) {
|
||||
states[source].epsilon.append(ContentEpsilonEdge(target: target, guards: guards, actions: actions))
|
||||
}
|
||||
|
||||
private mutating func particle(_ particle: PureXML.Schema.Particle) -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let loop = addState()
|
||||
let accept = addState()
|
||||
let counter = counters.count
|
||||
let bodyNullable = termIsNullable(particle.term)
|
||||
counters.append(ContentCounterScope(range: particle.occurrenceRange, nullableBody: bodyNullable))
|
||||
let body = term(particle.term)
|
||||
let resetAll = [ContentCounterAction.reset(counter)] + body.counters.map(ContentCounterAction.reset)
|
||||
addEpsilon(from: start, to: loop, actions: resetAll)
|
||||
addEpsilon(
|
||||
from: loop,
|
||||
to: accept,
|
||||
guards: [
|
||||
.canExit(
|
||||
counter: counter,
|
||||
minimum: particle.occurrenceRange.minimum,
|
||||
nullableBody: bodyNullable,
|
||||
),
|
||||
],
|
||||
)
|
||||
addEpsilon(
|
||||
from: loop,
|
||||
to: body.start,
|
||||
guards: [.belowMaximum(counter: counter, maximum: particle.occurrenceRange.maximum)],
|
||||
actions: body.counters.map(ContentCounterAction.reset),
|
||||
)
|
||||
addEpsilon(from: body.accept, to: loop, actions: [.increment(counter)])
|
||||
return ContentNFAFragment(
|
||||
start: start,
|
||||
accept: accept,
|
||||
counters: [counter] + body.counters,
|
||||
nullable: particleIsNullable(particle),
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func term(_ term: PureXML.Schema.Term) -> ContentNFAFragment {
|
||||
switch term {
|
||||
case let .element(name, type, _, constraint, _, _):
|
||||
let fragment = labeled(.name(name))
|
||||
states[fragment.start].elementType = type
|
||||
states[fragment.start].valueConstraint = constraint
|
||||
return fragment
|
||||
case let .wildcard(wildcard):
|
||||
return labeled(.wildcard(wildcard))
|
||||
case let .group(group):
|
||||
return self.group(group)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func labeled(_ label: TermLabel) -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
states[start].label = label
|
||||
states[start].target = accept
|
||||
return ContentNFAFragment(start: start, accept: accept, counters: [], nullable: false)
|
||||
}
|
||||
|
||||
private mutating func group(_ group: PureXML.Schema.Group) -> ContentNFAFragment {
|
||||
// `all` is validated by counting, not in the NFA; treat it as a
|
||||
// sequence here so a nested occurrence still parses.
|
||||
group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
|
||||
}
|
||||
|
||||
private mutating func sequence(_ particles: [PureXML.Schema.Particle]) -> ContentNFAFragment {
|
||||
guard !particles.isEmpty else { return labeledEmpty() }
|
||||
var first: Int?
|
||||
var last: Int?
|
||||
var fragmentCounters: [Int] = []
|
||||
var nullable = true
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
if let previous = last { addEpsilon(from: previous, to: part.start) } else { first = part.start }
|
||||
last = part.accept
|
||||
fragmentCounters.append(contentsOf: part.counters)
|
||||
nullable = nullable && part.nullable
|
||||
}
|
||||
return ContentNFAFragment(
|
||||
start: first ?? addState(),
|
||||
accept: last ?? addState(),
|
||||
counters: fragmentCounters,
|
||||
nullable: nullable,
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func choice(_ particles: [PureXML.Schema.Particle]) -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
var fragmentCounters: [Int] = []
|
||||
var nullable = false
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
addEpsilon(from: start, to: part.start)
|
||||
addEpsilon(from: part.accept, to: accept)
|
||||
fragmentCounters.append(contentsOf: part.counters)
|
||||
nullable = nullable || part.nullable
|
||||
}
|
||||
return ContentNFAFragment(
|
||||
start: start,
|
||||
accept: accept,
|
||||
counters: fragmentCounters,
|
||||
nullable: nullable,
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func labeledEmpty() -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
addEpsilon(from: start, to: accept)
|
||||
return ContentNFAFragment(start: start, accept: accept, counters: [], nullable: true)
|
||||
}
|
||||
|
||||
private func particleIsNullable(_ particle: PureXML.Schema.Particle) -> Bool {
|
||||
particle.occurrenceRange.minimum.isZero || termIsNullable(particle.term)
|
||||
}
|
||||
|
||||
private func termIsNullable(_ term: PureXML.Schema.Term) -> Bool {
|
||||
switch term {
|
||||
case .element, .wildcard:
|
||||
false
|
||||
case let .group(group):
|
||||
switch group.compositor {
|
||||
case .sequence, .all:
|
||||
group.particles.allSatisfy(particleIsNullable)
|
||||
case .choice:
|
||||
group.particles.contains(where: particleIsNullable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Sources/Schema/OccurrenceRange.swift
Normal file
106
Sources/Schema/OccurrenceRange.swift
Normal file
@ -0,0 +1,106 @@
|
||||
public extension PureXML.Schema {
|
||||
/// A non-negative integer magnitude preserved as normalized decimal text.
|
||||
///
|
||||
/// XSD occurrence attributes are `nonNegativeInteger` values and may exceed
|
||||
/// the platform `Int` range. This type compares such values without converting
|
||||
/// them to machine integers.
|
||||
struct NonNegativeDecimal: Sendable, Hashable, Comparable, CustomStringConvertible {
|
||||
private var digits: String
|
||||
|
||||
public init(_ value: Int) {
|
||||
precondition(value >= 0, "occurrence values must be non-negative")
|
||||
digits = String(value)
|
||||
}
|
||||
|
||||
public init?(lexical value: String) {
|
||||
var raw = Substring(value.trimmingXMLWhitespace())
|
||||
if raw.first == "+" { raw = raw.dropFirst() }
|
||||
guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.drop { $0 == "0" }
|
||||
digits = trimmed.isEmpty ? "0" : String(trimmed)
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
digits
|
||||
}
|
||||
|
||||
var isZero: Bool {
|
||||
digits == "0"
|
||||
}
|
||||
|
||||
func clamped(to limit: Int) -> Int {
|
||||
precondition(limit >= 0, "occurrence clamp limit must be non-negative")
|
||||
if isGreaterThan(limit) { return limit }
|
||||
return Int(digits) ?? limit
|
||||
}
|
||||
|
||||
func isGreaterThan(_ value: Int) -> Bool {
|
||||
precondition(value >= 0, "occurrence comparison value must be non-negative")
|
||||
let other = String(value)
|
||||
if digits.count != other.count { return digits.count > other.count }
|
||||
return digits > other
|
||||
}
|
||||
|
||||
func isLessThanOrEqual(to value: Int) -> Bool {
|
||||
!isGreaterThan(value)
|
||||
}
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
if lhs.digits.count != rhs.digits.count {
|
||||
return lhs.digits.count < rhs.digits.count
|
||||
}
|
||||
return lhs.digits < rhs.digits
|
||||
}
|
||||
}
|
||||
|
||||
/// The upper occurrence bound of an XSD particle.
|
||||
enum OccurrenceUpper: Sendable, Hashable, Equatable {
|
||||
case finite(NonNegativeDecimal)
|
||||
case unbounded
|
||||
|
||||
public init(_ value: Int?) {
|
||||
if let value {
|
||||
self = .finite(NonNegativeDecimal(value))
|
||||
} else {
|
||||
self = .unbounded
|
||||
}
|
||||
}
|
||||
|
||||
public var isZero: Bool {
|
||||
if case let .finite(value) = self { return value.isZero }
|
||||
return false
|
||||
}
|
||||
|
||||
func clamped(to limit: Int) -> Int? {
|
||||
switch self {
|
||||
case let .finite(value): value.clamped(to: limit)
|
||||
case .unbounded: nil
|
||||
}
|
||||
}
|
||||
|
||||
func isGreaterThan(_ value: Int) -> Bool {
|
||||
switch self {
|
||||
case let .finite(upper): upper.isGreaterThan(value)
|
||||
case .unbounded: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full occurrence range of an XSD particle.
|
||||
struct OccurrenceRange: Sendable, Hashable, Equatable {
|
||||
public var minimum: NonNegativeDecimal
|
||||
public var maximum: OccurrenceUpper
|
||||
|
||||
public init(minimum: NonNegativeDecimal = NonNegativeDecimal(1), maximum: OccurrenceUpper = .finite(NonNegativeDecimal(1))) {
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
}
|
||||
|
||||
public init(minimum: Int = 1, maximum: Int? = 1) {
|
||||
self.minimum = NonNegativeDecimal(minimum)
|
||||
self.maximum = OccurrenceUpper(maximum)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -83,10 +83,23 @@ extension PureXML.Schema {
|
||||
}
|
||||
|
||||
static func occurrence(_ node: XSDTree) -> (min: Int, max: Int?) {
|
||||
let minimum = attribute(node, "minOccurs").flatMap(Int.init) ?? 1
|
||||
let maximumText = attribute(node, "maxOccurs")
|
||||
let maximum: Int? = maximumText == "unbounded" ? nil : (maximumText.flatMap(Int.init) ?? 1)
|
||||
return (minimum, maximum)
|
||||
let range = occurrenceRange(node)
|
||||
return (
|
||||
range.minimum.clamped(to: Int.max),
|
||||
range.maximum.clamped(to: Int.max),
|
||||
)
|
||||
}
|
||||
|
||||
static func occurrenceRange(_ node: XSDTree) -> OccurrenceRange {
|
||||
let minimum = attribute(node, "minOccurs")
|
||||
.flatMap { NonNegativeDecimal(lexical: $0) } ?? NonNegativeDecimal(1)
|
||||
let maximumText = attribute(node, "maxOccurs")?.trimmingXMLWhitespace()
|
||||
let maximum: OccurrenceUpper = if maximumText == "unbounded" {
|
||||
.unbounded
|
||||
} else {
|
||||
.finite(maximumText.flatMap { NonNegativeDecimal(lexical: $0) } ?? NonNegativeDecimal(1))
|
||||
}
|
||||
return OccurrenceRange(minimum: minimum, maximum: maximum)
|
||||
}
|
||||
|
||||
/// Collects the schema's own element and a transitive closure of the
|
||||
|
||||
@ -29,22 +29,26 @@ extension PureXML.Schema.XSDParser {
|
||||
for child in XSDNode.elementChildren(node) {
|
||||
if let member = particle(child, context) { particles.append(member) }
|
||||
}
|
||||
let (minimum, maximum) = XSDNode.occurrence(node)
|
||||
if compositor == .all, minimum == 0 {
|
||||
particles = particles.map { Particle(minOccurs: 0, maxOccurs: $0.maxOccurs, term: $0.term) }
|
||||
let occurrence = XSDNode.occurrenceRange(node)
|
||||
if compositor == .all, occurrence.minimum.isZero {
|
||||
particles = particles.map {
|
||||
Particle(
|
||||
occurrenceRange: .init(minimum: .init(0), maximum: $0.occurrenceRange.maximum),
|
||||
term: $0.term,
|
||||
)
|
||||
}
|
||||
}
|
||||
return Particle(
|
||||
minOccurs: minimum,
|
||||
maxOccurs: maximum,
|
||||
occurrenceRange: occurrence,
|
||||
term: .group(Group(compositor: compositor, particles: particles)),
|
||||
)
|
||||
}
|
||||
|
||||
private static func particle(_ node: PureXML.Model.TreeNode, _ context: XSDContext) -> Particle? {
|
||||
let (minimum, maximum) = XSDNode.occurrence(node)
|
||||
let occurrence = XSDNode.occurrenceRange(node)
|
||||
switch XSDNode.localName(node) {
|
||||
case "element":
|
||||
return elementParticle(node, minimum, maximum, context)
|
||||
return elementParticle(node, occurrence, context)
|
||||
case "sequence":
|
||||
return groupParticle(node, .sequence, context)
|
||||
case "choice":
|
||||
@ -52,22 +56,21 @@ extension PureXML.Schema.XSDParser {
|
||||
case "all":
|
||||
return groupParticle(node, .all, context)
|
||||
case "group":
|
||||
return groupReferenceParticle(node, minimum, maximum, context)
|
||||
return groupReferenceParticle(node, occurrence, context)
|
||||
case "any":
|
||||
return Particle(minOccurs: minimum, maxOccurs: maximum, term: .wildcard(wildcard(node, context)))
|
||||
return Particle(occurrenceRange: occurrence, term: .wildcard(wildcard(node, context)))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func elementParticle(_ node: PureXML.Model.TreeNode, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle {
|
||||
private static func elementParticle(_ node: PureXML.Model.TreeNode, _ occurrence: PureXML.Schema.OccurrenceRange, _ context: XSDContext) -> Particle {
|
||||
if let ref = XSDNode.attribute(node, "ref") {
|
||||
return referenceParticle(ref, minimum, maximum, context, node: node)
|
||||
return referenceParticle(ref, occurrence, context, node: node)
|
||||
}
|
||||
let name = XSDNode.attribute(node, "name") ?? ""
|
||||
return Particle(
|
||||
minOccurs: minimum,
|
||||
maxOccurs: maximum,
|
||||
occurrenceRange: occurrence,
|
||||
term: .element(
|
||||
name: localElementName(name, XSDNode.attribute(node, "form"), context),
|
||||
type: elementType(node, context),
|
||||
@ -79,7 +82,7 @@ extension PureXML.Schema.XSDParser {
|
||||
)
|
||||
}
|
||||
|
||||
private static func groupReferenceParticle(_ node: PureXML.Model.TreeNode, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle? {
|
||||
private static func groupReferenceParticle(_ node: PureXML.Model.TreeNode, _ occurrence: PureXML.Schema.OccurrenceRange, _ context: XSDContext) -> Particle? {
|
||||
guard let ref = XSDNode.attribute(node, "ref") else { return nil }
|
||||
let name = XSDNode.stripPrefix(ref)
|
||||
if context.visitingGroups.contains(name) {
|
||||
@ -92,7 +95,7 @@ extension PureXML.Schema.XSDParser {
|
||||
guard let inner = modelGroup(in: base, scoped.visiting(name)) else {
|
||||
return nil
|
||||
}
|
||||
return Particle(minOccurs: minimum, maxOccurs: maximum, term: inner.term)
|
||||
return Particle(occurrenceRange: occurrence, term: inner.term)
|
||||
}
|
||||
guard let definition = context.groups[name] else {
|
||||
return nil
|
||||
@ -101,7 +104,7 @@ extension PureXML.Schema.XSDParser {
|
||||
guard let inner = modelGroup(in: definition, scoped.visiting(name)) else {
|
||||
return nil
|
||||
}
|
||||
return Particle(minOccurs: minimum, maxOccurs: maximum, term: inner.term)
|
||||
return Particle(occurrenceRange: occurrence, term: inner.term)
|
||||
}
|
||||
|
||||
/// The particle for an `<xs:element ref="...">`. An abstract head may not appear
|
||||
@ -113,6 +116,15 @@ extension PureXML.Schema.XSDParser {
|
||||
_ maximum: Int?,
|
||||
_ context: PureXML.Schema.XSDContext,
|
||||
node: XSDTree? = nil,
|
||||
) -> PureXML.Schema.Particle {
|
||||
referenceParticle(ref, .init(minimum: minimum, maximum: maximum), context, node: node)
|
||||
}
|
||||
|
||||
static func referenceParticle(
|
||||
_ ref: String,
|
||||
_ occurrence: PureXML.Schema.OccurrenceRange,
|
||||
_ context: PureXML.Schema.XSDContext,
|
||||
node: XSDTree? = nil,
|
||||
) -> PureXML.Schema.Particle {
|
||||
let name = PureXML.Schema.XSDNode.stripPrefix(ref)
|
||||
let bindings = node.map { PureXML.Schema.XSDParser.namespaceBindingsInScope(of: $0, defaultBindings: context.namespaceBindings) }
|
||||
@ -125,10 +137,10 @@ extension PureXML.Schema.XSDParser {
|
||||
// member's own namespace, even across imported namespaces.
|
||||
let alternatives = head + (context.substitutions[headKey] ?? []).map { PureXML.Schema.XSDParser.unpackElementName($0) }
|
||||
if alternatives.count == 1 {
|
||||
return PureXML.Schema.Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(alternatives[0].0, alternatives[0].1, context))
|
||||
return PureXML.Schema.Particle(occurrenceRange: occurrence, term: elementReferenceTerm(alternatives[0].0, alternatives[0].1, context))
|
||||
}
|
||||
let members = alternatives.map { PureXML.Schema.Particle(term: elementReferenceTerm($0.0, $0.1, context)) }
|
||||
return PureXML.Schema.Particle(minOccurs: minimum, maxOccurs: maximum, term: .group(.init(compositor: .choice, particles: members)))
|
||||
return PureXML.Schema.Particle(occurrenceRange: occurrence, term: .group(.init(compositor: .choice, particles: members)))
|
||||
}
|
||||
|
||||
/// The term for a reference to a global element, in the resolved `namespace`.
|
||||
|
||||
@ -1,52 +1,103 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// Guards the content-model NFA builder against occurrence-explosion (#129).
|
||||
/// The per-particle unroll cap bounds one repetition, but nested high-`maxOccurs`
|
||||
/// particles multiply: the XSTS msMeta Particles set drove the builder to 8 GB
|
||||
/// and OOM-killed the suite. A total state ceiling caps the whole automaton,
|
||||
/// degrading further repetition to star. This locks that ceiling in: the
|
||||
/// pathological model must build, stay bounded, and still match without
|
||||
/// crashing, while ordinary models are untouched.
|
||||
@Suite("Content NFA state budget")
|
||||
/// Guards the counted content-model automaton against occurrence explosion (#129).
|
||||
/// Occurrence bounds must affect counters, not program size, and finite bounds
|
||||
/// must stay exact instead of widening to `*`.
|
||||
@Suite("Counted content automaton")
|
||||
struct ContentNFAStateBudgetTests {
|
||||
private func element(_ name: String, min: Int, max: Int?) -> PureXML.Schema.Particle {
|
||||
private typealias Schema = PureXML.Schema
|
||||
private typealias Name = PureXML.Model.QualifiedName
|
||||
|
||||
private func decimal(_ value: String) throws -> Schema.NonNegativeDecimal {
|
||||
try #require(Schema.NonNegativeDecimal(lexical: value))
|
||||
}
|
||||
|
||||
private func element(_ name: String, min: Int = 1, max: Int? = 1) -> Schema.Particle {
|
||||
.init(minOccurs: min, maxOccurs: max, term: .element(name: .init(name), type: nil, typeName: nil))
|
||||
}
|
||||
|
||||
/// A sequence repeated `min` times, each repeat holding an element repeated
|
||||
/// `min` times: naively `min * min` automaton states.
|
||||
private func nested(min: Int) -> PureXML.Schema.Particle {
|
||||
let inner = element("a", min: min, max: min)
|
||||
return .init(
|
||||
minOccurs: min,
|
||||
maxOccurs: min,
|
||||
term: .group(.init(compositor: .sequence, particles: [inner])),
|
||||
)
|
||||
private func element(_ name: String, range: Schema.OccurrenceRange) -> Schema.Particle {
|
||||
.init(occurrenceRange: range, term: .element(name: .init(name), type: nil, typeName: nil))
|
||||
}
|
||||
|
||||
@Test("A nested occurrence explosion stays bounded instead of exhausting memory")
|
||||
private func group(_ compositor: Schema.Compositor, _ particles: [Schema.Particle], min: Int = 1, max: Int? = 1) -> Schema.Particle {
|
||||
.init(minOccurs: min, maxOccurs: max, term: .group(.init(compositor: compositor, particles: particles)))
|
||||
}
|
||||
|
||||
private func names(_ values: String...) -> [Name] {
|
||||
values.map(Name.init)
|
||||
}
|
||||
|
||||
@Test("Finite exact occurrence rejects too few and too many children")
|
||||
func test_exactFiniteOccurrence() {
|
||||
let nfa = Schema.ContentNFABuilder.build(element("a", min: 2, max: 2))
|
||||
#expect(!nfa.matchesWhole(names("a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a")))
|
||||
#expect(!nfa.matchesWhole(names("a", "a", "a")))
|
||||
}
|
||||
|
||||
@Test("Finite optional tail does not widen to star")
|
||||
func test_finiteOptionalTailDoesNotWiden() {
|
||||
let nfa = Schema.ContentNFABuilder.build(element("a", min: 0, max: 2))
|
||||
#expect(nfa.matchesWhole([]))
|
||||
#expect(nfa.matchesWhole(names("a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a")))
|
||||
#expect(!nfa.matchesWhole(names("a", "a", "a")))
|
||||
}
|
||||
|
||||
@Test("Huge finite upper bound stays exact without proportional states")
|
||||
func test_hugeFiniteUpperBound() throws {
|
||||
let huge = try decimal("100000000000000000000")
|
||||
let particle = element(
|
||||
"a",
|
||||
range: .init(minimum: .init(2), maximum: .finite(huge)),
|
||||
)
|
||||
let nfa = Schema.ContentNFABuilder.build(particle)
|
||||
#expect(nfa.states.count < 16)
|
||||
#expect(!nfa.matchesWhole(names("a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a", "a", "a")))
|
||||
}
|
||||
|
||||
@Test("Nested finite occurrences compose exactly")
|
||||
func test_nestedFiniteOccurrences() {
|
||||
let inner = element("a", min: 2, max: 3)
|
||||
let outer = group(.sequence, [inner], min: 2, max: 2)
|
||||
let nfa = Schema.ContentNFABuilder.build(outer)
|
||||
#expect(!nfa.matchesWhole(names("a", "a", "a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a", "a", "a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a", "a", "a", "a")))
|
||||
#expect(nfa.matchesWhole(names("a", "a", "a", "a", "a", "a")))
|
||||
#expect(!nfa.matchesWhole(names("a", "a", "a", "a", "a", "a", "a")))
|
||||
}
|
||||
|
||||
@Test("A huge required prefix does not offer the following particle early")
|
||||
func test_hugeMinimumFollowSet() throws {
|
||||
let huge = try decimal("1000000000000")
|
||||
let requiredA = element(
|
||||
"a",
|
||||
range: .init(minimum: huge, maximum: .finite(huge)),
|
||||
)
|
||||
let particle = group(.sequence, [requiredA, element("b")])
|
||||
let nfa = Schema.ContentNFABuilder.build(particle)
|
||||
|
||||
let follow = nfa.follow(after: names("a", "a"))
|
||||
let allowedNames = follow.allowed.compactMap { label -> String? in
|
||||
if case let .name(name) = label { return name.localName }
|
||||
return nil
|
||||
}
|
||||
|
||||
#expect(Set(allowedNames) == ["a"])
|
||||
#expect(!follow.complete)
|
||||
}
|
||||
|
||||
@Test("A nested occurrence explosion stays structurally bounded")
|
||||
func test_nestedExplosionBounded() {
|
||||
// 16384 * 16384 ~ 2.7e8 states without a total cap; the ceiling is 2^20.
|
||||
let nfa = PureXML.Schema.ContentNFABuilder.build(nested(min: 16384))
|
||||
// Bounded to the ceiling plus at most one in-flight term expansion,
|
||||
// far below the naive product that previously allocated until death.
|
||||
#expect(nfa.states.count < 1_500_000)
|
||||
// The degraded automaton must still answer queries without crashing.
|
||||
let names = Array(repeating: PureXML.Model.QualifiedName("a"), count: 32)
|
||||
_ = nfa.matchesWhole(names)
|
||||
}
|
||||
|
||||
@Test("Ordinary content models are unaffected by the ceiling")
|
||||
func test_ordinaryModelUnchanged() {
|
||||
let particle = PureXML.Schema.Particle(
|
||||
minOccurs: 1,
|
||||
maxOccurs: nil,
|
||||
term: .group(.init(compositor: .sequence, particles: [element("a", min: 1, max: 1)])),
|
||||
)
|
||||
let nfa = PureXML.Schema.ContentNFABuilder.build(particle)
|
||||
#expect(nfa.states.count < 64)
|
||||
#expect(nfa.matchesWhole([.init("a"), .init("a"), .init("a")]))
|
||||
#expect(!nfa.matchesWhole([.init("b")]))
|
||||
let inner = element("a", min: 16384, max: 16384)
|
||||
let nested = group(.sequence, [inner], min: 16384, max: 16384)
|
||||
let nfa = Schema.ContentNFABuilder.build(nested)
|
||||
#expect(nfa.states.count < 32)
|
||||
#expect(!nfa.matchesWhole(Array(repeating: Name("a"), count: 32)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,23 @@ struct XSDSchemaTests {
|
||||
#expect(try !validate(xsd, "<book isbn=\"1\"><title>T</title><year>nope</year></book>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A huge finite maxOccurs is preserved beyond Int range")
|
||||
func test_hugeFiniteMaxOccurs() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="root">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="item" type="xs:string" maxOccurs="100000000000000000000"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
#expect(try validate(xsd, "<root><item/><item/></root>").isEmpty)
|
||||
#expect(try !validate(xsd, "<root/>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A named complex type referenced by elements")
|
||||
func test_namedComplexType() throws {
|
||||
let xsd = """
|
||||
|
||||
@ -48,8 +48,9 @@ The relevant literature line is:
|
||||
- Kilpeläinen 2011: the principled XML Schema determinism tool is a counting
|
||||
automaton, not occurrence unrolling.
|
||||
|
||||
These are witnesses for the algorithm family. PureXML's implementation still has
|
||||
to be derived against its own `Particle` model and verified locally.
|
||||
These are witnesses for the algorithm family. PureXML's instance-time matcher now
|
||||
implements the counted shape against its own `Particle` model; counted UPA remains
|
||||
separate.
|
||||
|
||||
## Correctness claims
|
||||
|
||||
@ -57,9 +58,9 @@ Every claim below is labeled per proof discipline.
|
||||
|
||||
| Claim | Status | Meaning |
|
||||
|---|---|---|
|
||||
| Counted matcher accepts exactly the same child-name language as the XSD particle tree for `element`, `wildcard`, `sequence`, and `choice` with `minOccurs`/`maxOccurs`. | theorem target | Proved by structural induction over the particle tree once the implementation replaces `ContentNFA`. |
|
||||
| Counted matcher keeps per-child matched-particle attribution. | theorem target under UPA | For UPA-valid content models, each consumed element is matched to exactly one particle; that particle's type/value metadata is the one used by child validation. |
|
||||
| Counted matcher build size is independent of numeric occurrence magnitudes. | theorem target | Program states and counters are proportional to particle nodes, not to `minOccurs` or `maxOccurs` values. |
|
||||
| Counted matcher accepts exactly the same child-name language as the XSD particle tree for `element`, `wildcard`, `sequence`, and `choice` with `minOccurs`/`maxOccurs`. | theorem target, implemented | `ContentMatcher.swift` now uses counted configurations; the remaining work is writing the full structural proof against nullable nested particles. |
|
||||
| Counted matcher keeps per-child matched-particle attribution. | theorem target under UPA, implemented | For UPA-valid content models, each consumed element is matched to exactly one particle; that particle's type/value metadata is the one used by child validation. |
|
||||
| Counted matcher build size is independent of numeric occurrence magnitudes. | theorem, implemented | `ContentNFABuilder` allocates one counter scope per particle occurrence and a constant number of states/edges per particle; high-bound tests assert state count stays structural. |
|
||||
| Counted matcher runtime is bounded for interactive validation. | theorem target | For `N` child elements, `S` program states, and `D` counter scopes, validation is bounded by `O(N * S * D)` time and `O(S * D)` live matcher memory. The constants are schema-structural, not occurrence-magnitude structural. |
|
||||
| `all` groups remain exact. | theorem already implemented, out of scope for NFA replacement | `all` is validated by direct member counts in `ComplexValidator.matchesAll` and `allStructureErrors`; the counted automaton does not need to encode it initially. |
|
||||
| Counted UPA/determinism replaces `positionCap`. | blocked design frontier | The instance matcher can be replaced first. Deleting `positionCap` requires a separate counted determinism pass or a proof that the existing clamp is exact for XSD's admitted content models. |
|
||||
@ -296,23 +297,24 @@ current caps.
|
||||
|
||||
## Migration plan
|
||||
|
||||
1. Add `NonNegativeDecimal`, `OccurrenceUpper`, and `OccurrenceRange` while
|
||||
1. Done 2026-06-19: add `NonNegativeDecimal`, `OccurrenceUpper`, and `OccurrenceRange` while
|
||||
preserving the existing `Particle` API for call sites that only need small
|
||||
integer comparisons.
|
||||
2. Add `CountedContentProgram` and a builder beside `ContentNFABuilder`.
|
||||
3. Add differential tests comparing old and counted matchers on ordinary models
|
||||
2. Done 2026-06-19: replace `ContentNFABuilder`'s unrolled body copies with a
|
||||
counted program behind the existing `ContentNFA` surface.
|
||||
3. Keep differential tests comparing DTD and XSD matchers on ordinary models
|
||||
below the current caps.
|
||||
4. Add high-bound tests that are exact and currently impossible without language
|
||||
4. Done 2026-06-19: add high-bound tests that are exact and previously impossible without language
|
||||
widening:
|
||||
- `a{2,2}` rejects one `a` and three `a`s;
|
||||
- `a{2,100000000000000000000}` accepts two `a`s and rejects one;
|
||||
- `a{0,2}` rejects three `a`s;
|
||||
- `(a{2,3}){2,2}` accepts four to six `a`s and rejects three/seven;
|
||||
- `sequence(a{1000000000000}, b)` never offers `b` before enough `a`s.
|
||||
5. Switch `ComplexValidatorContentModel`, `XSDCompletions`, and
|
||||
5. Done 2026-06-19: switch `ComplexValidatorContentModel`, `XSDCompletions`, and
|
||||
`matchedParticles` to the counted program.
|
||||
6. Delete `occursUnrollCap` and `totalStateCap`.
|
||||
7. Replace `ContentNFAStateBudgetTests` with exact high-bound tests. The old
|
||||
6. Done 2026-06-19: delete `occursUnrollCap` and `totalStateCap`.
|
||||
7. Done 2026-06-19: replace `ContentNFAStateBudgetTests` with exact high-bound tests. The old
|
||||
state-ceiling assertion should disappear because there is no state ceiling.
|
||||
8. Design and land the counted UPA pass; then delete `positionCap`.
|
||||
|
||||
@ -331,10 +333,10 @@ current caps.
|
||||
|
||||
This arc is complete only when all are true:
|
||||
|
||||
- `ContentMatcher.swift` contains no `occursUnrollCap`, `totalStateCap`, or
|
||||
- Done 2026-06-19: `ContentMatcher.swift` contains no `occursUnrollCap`, `totalStateCap`, or
|
||||
cap-triggered "degrade to star" path.
|
||||
- `ContentModelDeterminism.swift` contains no `positionCap` skip.
|
||||
- Tests include exact high-bound acceptance/rejection cases whose numeric bounds
|
||||
- Done 2026-06-19: tests include exact high-bound acceptance/rejection cases whose numeric bounds
|
||||
exceed any practical unroll count.
|
||||
- The proof labels in this document are upgraded from "theorem target" to
|
||||
"theorem" with code references.
|
||||
|
||||
@ -278,7 +278,7 @@ XSTS_ROOT=/private/tmp/xsts/xmlschema2006-11-06 swift test -c release --filter X
|
||||
|
||||
**Commit:** `fix(schema): close identity constraint instance tail`
|
||||
|
||||
### T9. Design the counting automaton separately
|
||||
### T9. Land the counted instance matcher, then counted UPA
|
||||
|
||||
**Files:** new `docs/design/` or `docs/plans/` design artifact,
|
||||
`Sources/Schema/ContentMatcher.swift`,
|
||||
@ -293,6 +293,9 @@ state a worst-case time and memory bound; prove high numeric occurrence bounds
|
||||
do not create proportional states.
|
||||
The design artifact is `docs/design/counted-content-automaton.md`; implementation
|
||||
must follow that proof plan rather than adding another cap.
|
||||
Status 2026-06-19: the `ContentMatcher` instance-time automaton now uses counted
|
||||
configurations and exact decimal occurrence bounds. `ContentModelDeterminism` still
|
||||
has `positionCap`, so this arc is not complete.
|
||||
|
||||
**Verifies:**
|
||||
|
||||
@ -302,7 +305,8 @@ swift test --filter ContentModelDeterminism
|
||||
swift test --filter ContentNFAStateBudgetTests
|
||||
```
|
||||
|
||||
**Commit:** `docs(schema): design counted content automaton`
|
||||
**Commits:** `docs(schema): design counted content automaton`,
|
||||
`fix(schema): count content-model occurrences`
|
||||
|
||||
## Constraints
|
||||
|
||||
|
||||
@ -82,7 +82,7 @@ critic for latent FPs the corpus misses), not a pattern sweep. A few (contested
|
||||
spec: schM4 reorder, attQ per-document vs per-composition id-scope) need adjudication,
|
||||
not code.
|
||||
|
||||
## Part 2: the caps are band-aids because matching unrolls occurrence values, not text
|
||||
## Part 2: the remaining cap is UPA determinism, not instance matching
|
||||
|
||||
Two engines, two postures.
|
||||
|
||||
@ -93,35 +93,30 @@ pathological nested group references; when hit it **silently skips the UPA check
|
||||
(`return nil`, ContentModelDeterminism.swift:85): a hidden false negative and a
|
||||
stopper-3 silent gap, but rare.
|
||||
|
||||
**Instance-time matcher (`ContentMatcher`)**, the one that runs on keystrokes, is
|
||||
the real problem. It builds the NFA by **unrolling** occurrences into states
|
||||
(`for _ in 0..<boundedMin { … addState() }`, ContentMatcher.swift:203–208), so NFA
|
||||
size is proportional to the **numeric value** of the occurrence bound, not the
|
||||
schema text: `maxOccurs="1000000000"` is 10 bytes that want a billion states. The
|
||||
caps (`occursUnrollCap = 16384` per particle, `totalStateCap = 2²⁰` total) clip it,
|
||||
but on hitting the ceiling the repetition **"degrades to star"** (treats a bounded
|
||||
`{m,n}` as unbounded → accepts content it should reject, a false negative;
|
||||
ContentMatcher.swift:211–214). This OOM-killed the suite at 8 GB before the cap
|
||||
(#129).
|
||||
**Instance-time matcher (`ContentMatcher`)**, the one that runs on keystrokes, no
|
||||
longer unrolls occurrences into states. The 2026-06-19 counted matcher stores XSD
|
||||
occurrence bounds as decimal magnitudes (`NonNegativeDecimal` / `OccurrenceRange`)
|
||||
and compiles each particle occurrence to a guarded counter loop. That deletes the
|
||||
old `occursUnrollCap` / `totalStateCap` path and its bounded-to-star widening. The
|
||||
regression tests now assert exact finite bounds, nested finite bounds, huge finite
|
||||
upper bounds beyond `Int`, and follow-set behavior before a huge required prefix is
|
||||
satisfied.
|
||||
|
||||
**Why a cap cannot become a bound by tuning.** The blowup is structural: unrolling
|
||||
encodes an O(log n)-byte numeric bound as n states. No ceiling fixes that without
|
||||
changing semantics at the ceiling.
|
||||
|
||||
**What an actual proof requires.** Replace unroll-into-states with a **counting
|
||||
automaton**: represent `{m,n}` with a counter/register, not m copies. Then:
|
||||
**What the remaining proof requires.** The instance matcher now uses a **counting
|
||||
automaton**: `{m,n}` is represented with a counter/register, not m copies. The
|
||||
remaining release proof has two parts:
|
||||
|
||||
- build size = O(schema text size), independent of the numeric bounds;
|
||||
- instance matching = O(input length × schema size), counter values bounded by the
|
||||
log-encoded bounds → polynomial, no blowup;
|
||||
- determinism-with-counters is decidable in polynomial time (Gelade–Martens–Neven /
|
||||
Kilpeläinen–Tuhkanen line on regexes with numeric occurrence constraints).
|
||||
- keep the implementation proof aligned with `docs/design/counted-content-automaton.md`
|
||||
for the counted matcher (`ContentMatcher.swift`);
|
||||
- replace the compile-time UPA `positionCap` skip with counted determinism.
|
||||
|
||||
That yields a provable worst-case time/space bound, removes **all** the caps, and
|
||||
closes the silent star-degradation and the `positionCap` skip. Stopper 4 (proven
|
||||
bounds) and stopper 3 (no silent debt) are the same fix here. It is a
|
||||
re-architecture of the matcher core, not a tweak, which is what makes it
|
||||
foundational rather than incremental.
|
||||
When both are done, Part 2 closes stopper 4 (proven bounds) and the remaining
|
||||
stopper-3 silent gap. Until then, the instance-time star-degradation is closed,
|
||||
but `positionCap` remains a named release blocker.
|
||||
|
||||
## If turned into work
|
||||
|
||||
@ -129,8 +124,8 @@ foundational rather than incremental.
|
||||
paired with the FP-guard test matrix (valid restrictions that must still pass)
|
||||
before tightening. Drive `invalid-schemas-accepted` / `invalid-instances-accepted`
|
||||
to 0 (M2, M3), FP gate sacred.
|
||||
- **Part 2:** a counter-automaton design for `ContentMatcher` (and the determinism
|
||||
automaton), with acceptance criteria = the `occursUnrollCap` / `totalStateCap` /
|
||||
`positionCap` caps deleted and a stated worst-case bound, no silent star-degradation
|
||||
(M4 "proven bounds"). Aligns with the performance epic (#139, #175–178). The
|
||||
design is now pinned in `docs/design/counted-content-automaton.md`.
|
||||
- **Part 2:** the `ContentMatcher` counter automaton has landed; the remaining
|
||||
acceptance criteria are deleting `ContentModelDeterminism.positionCap`, keeping
|
||||
the stated worst-case bound/proof current, and preserving no silent
|
||||
star-degradation (M4 "proven bounds"). Aligns with the performance epic (#139,
|
||||
#175–178). The design is pinned in `docs/design/counted-content-automaton.md`.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user