The instance-validation differential treated "PureXML rejects, libxml2 accepts"
as a PureXML false positive. That is unsound: libxml2's own content validation is
lenient and accepts some INVALID instances. A DIFF_SEED sweep surfaced exactly
such a case (seed 13371337): the model ( (a*|c*){1,3}, b? ){1,3} and the instance
<doc><b/><b/><a/><b/><c/><a/></doc>. libxml2 accepts it, but it is genuinely
INVALID (the three b's anchor three (choice,b?) segments, leaving the trailing
'ca' with no fourth segment), so PureXML's rejection is correct, not a false
positive. Verified against an external regex engine and exhaustive enumeration.
Add ContentModelOracle, an independent brute-force membership matcher over element
names, implemented separately from PureXML's matcher. The generated instances are
empty xs:string elements, so an instance is valid iff its element-name sequence is
in the model's language, which this oracle decides exactly. The generator now
emits the structured ContentModel alongside the schema string (group references
inlined), and the differential classifies a false positive / false negative
against the ORACLE as ground truth; libxml2 is kept as a secondary cross-check
whose disagreements with the oracle are counted (its leniency) but never charged
to PureXML.
The oracle is unit-tested (ContentModelOracleTests), including the libxml2-leniency
case it exists to catch. Over a sweep of seeds the differential now reports zero
oracle-adjudicated false positives and zero false negatives, so PureXML's content
matcher agrees with the independent oracle on every generated case; the default
seed is unchanged. Test-only; no library behaviour change.
107 lines
4.5 KiB
Swift
107 lines
4.5 KiB
Swift
@testable import PureXML
|
|
|
|
/// An independent, brute-force content-model membership oracle over element names,
|
|
/// deliberately implemented separately from PureXML's matcher so the instance
|
|
/// differential can adjudicate a disagreement with libxml2: libxml2's content
|
|
/// validation has leniency bugs (it accepts some invalid nested/nullable/unbounded
|
|
/// models), so "PureXML rejects, libxml2 accepts" is not by itself a PureXML fault.
|
|
/// The generated instances are empty `xs:string` elements, so an instance is valid
|
|
/// iff its element-name sequence is in this language; this oracle is that decision,
|
|
/// verified against an external regex engine on the known cases.
|
|
indirect enum ContentModel: Sendable {
|
|
case element(String)
|
|
case any
|
|
case sequence([ContentModel])
|
|
case choice([ContentModel])
|
|
/// An `xs:all` group; each member is its own `occurs(element, min, 1)`.
|
|
case all([ContentModel])
|
|
case occurs(ContentModel, Int, Int?)
|
|
}
|
|
|
|
enum ContentModelOracle {
|
|
/// Whether the model can match the empty sequence.
|
|
static func nullable(_ model: ContentModel) -> Bool {
|
|
switch model {
|
|
case .element, .any: false
|
|
case let .sequence(parts): parts.allSatisfy(nullable)
|
|
case let .choice(parts): parts.contains(where: nullable)
|
|
case let .all(parts): parts.allSatisfy(nullable)
|
|
case let .occurs(sub, low, _): low == 0 || nullable(sub)
|
|
}
|
|
}
|
|
|
|
/// Every position reachable after matching `model` starting at `start`.
|
|
static func ends(_ model: ContentModel, _ tokens: [String], _ start: Int) -> Set<Int> {
|
|
switch model {
|
|
case let .element(name):
|
|
return start < tokens.count && tokens[start] == name ? [start + 1] : []
|
|
case .any:
|
|
return start < tokens.count ? [start + 1] : []
|
|
case let .sequence(parts):
|
|
var positions: Set<Int> = [start]
|
|
for part in parts {
|
|
var next: Set<Int> = []
|
|
for position in positions {
|
|
next.formUnion(ends(part, tokens, position))
|
|
}
|
|
positions = next
|
|
if positions.isEmpty { break }
|
|
}
|
|
return positions
|
|
case let .choice(parts):
|
|
var out: Set<Int> = []
|
|
for part in parts {
|
|
out.formUnion(ends(part, tokens, start))
|
|
}
|
|
return out
|
|
case let .all(parts):
|
|
return allEnds(parts, Array(repeating: false, count: parts.count), tokens, start)
|
|
case let .occurs(sub, low, high):
|
|
return occursEnds(sub, low, high, tokens, start)
|
|
}
|
|
}
|
|
|
|
/// Positions reachable by matching `sub` between `low` and `high` times. A
|
|
/// nullable `sub` is bounded by stopping once the reachable set stops growing.
|
|
private static func occursEnds(_ sub: ContentModel, _ low: Int, _ high: Int?, _ tokens: [String], _ start: Int) -> Set<Int> {
|
|
var byReps: [Set<Int>] = [[start]]
|
|
let cap = min(high ?? Int.max, tokens.count + low + 2)
|
|
var reps = 0
|
|
while reps < cap {
|
|
var next: Set<Int> = []
|
|
for position in byReps[reps] {
|
|
next.formUnion(ends(sub, tokens, position))
|
|
}
|
|
byReps.append(next)
|
|
reps += 1
|
|
let priorPositions = byReps[0 ..< reps].reduce(into: Set<Int>()) { $0.formUnion($1) }
|
|
if next.isSubset(of: priorPositions), reps >= low { break }
|
|
}
|
|
var out: Set<Int> = []
|
|
if low == 0 { out.insert(start) }
|
|
for count in low ... cap where count < byReps.count {
|
|
out.formUnion(byReps[count])
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// Positions reachable by matching the remaining (not-`done`) members of an
|
|
/// `xs:all` group in any order; a member that is nullable may be skipped.
|
|
private static func allEnds(_ members: [ContentModel], _ done: [Bool], _ tokens: [String], _ start: Int) -> Set<Int> {
|
|
var out: Set<Int> = []
|
|
if members.indices.allSatisfy({ done[$0] || nullable(members[$0]) }) { out.insert(start) }
|
|
for index in members.indices where !done[index] {
|
|
for end in ends(members[index], tokens, start) where end > start {
|
|
var next = done
|
|
next[index] = true
|
|
out.formUnion(allEnds(members, next, tokens, end))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
static func matches(_ model: ContentModel, _ tokens: [String]) -> Bool {
|
|
ends(model, tokens, 0).contains(tokens.count)
|
|
}
|
|
}
|