PureXML/Tests/ContentModelOracle.swift
Mihaela Mihaljevic 41338c9e3f
test(schema): adjudicate the instance differential with an independent oracle, not libxml2 alone (#386)
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.
2026-06-29 12:32:08 +02:00

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)
}
}