perf(schema): memoize group expansion in the compiled content model

Compiling a schema with deeply nested, multiply-referenced model groups was
exponential: groupReferenceParticle inlined each <xs:group ref> into the Particle
content model, so g0=(g1,g1), g1=(g2,g2), ... built 2^K particles (depth 14 =
0.21s, depth 20 ~13-18s). The expansion is now memoized per (group definition,
cycle-visiting set) in a per-compile GroupParticleMemo shared across the value
copies XSDContext.scoped/visiting make, so sibling references share it and the
build drops to O(groups) (depth 40 now ~0.016s; previously infeasible).

The shared content term is reused by value (copy-on-write), so the compiled model
is identical and validation is unchanged: the XSTS gate holds (invalid-schemas-
accepted 15, valid-schemas-rejected 0, instance buckets unchanged) and the
streaming-vs-tree fuzz differential still passes. This closes the schema-compile
half of the interactive-safety group blowup (companion to the positionCap
determinism fix); a depth-40 compile test guards against regression. Full suite,
style, namespacing, and WASM all pass.
This commit is contained in:
Mihaela Mihaljevic 2026-06-22 23:12:11 +02:00
parent 6f8222a840
commit bc0638e201
5 changed files with 40 additions and 2 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
extension PureXML.Schema {
/// The key under which an `<xs:group>` reference's expansion is memoized: the
/// group DEFINITION node and the cycle-visiting set at the reference site. The
/// expansion is determined by these (the namespace scope is fixed per
/// definition), so two references that share them share the result.
struct GroupParticleMemoKey: Hashable {
let definition: ObjectIdentifier
let visiting: Set<String>
}
/// Memoizes group-reference expansion during one schema compile. A
/// multiply-referenced nested group (`g0=(g1,g1), g1=(g2,g2), …`) otherwise
/// rebuilds each subtree per reference, an exponential `2^K` compile; sharing the
/// expansion across sibling references drops the build to `O(groups)`. The
/// resulting `Particle` is semantically identical (a value type, so the shared
/// content term is copy-on-write), only built once. A reference type so it is
/// shared across the value copies `XSDContext.scoped`/`visiting` make; one
/// instance per compile.
final class GroupParticleMemo {
var store: [GroupParticleMemoKey: PureXML.Schema.Particle] = [:]
}
}

View File

@ -284,6 +284,7 @@ extension PureXML.Schema {
/// refs can be expanded), and a guard against cyclic group references.
struct XSDContext {
var simpleTypes: [String: SimpleType]
let groupMemo = GroupParticleMemo()
var attributeGroups: [String: XSDTree]
/// Attribute-group definitions from included schemas before a `redefine`
/// overlay replaces them; used when a redefinition self-references its

View File

@ -98,10 +98,19 @@ extension PureXML.Schema.XSDParser {
guard let definition = context.groups[name] else {
return nil
}
// Memoize the expansion: it is fixed by the definition and the visiting set
// (scope is fixed per definition), so sibling references share it and a
// multiply-referenced nested group compiles in O(groups), not 2^K. The
// reference site's own occurrence still wraps the shared content term.
let key = PureXML.Schema.GroupParticleMemoKey(definition: ObjectIdentifier(definition), visiting: context.visitingGroups)
if let cached = context.groupMemo.store[key] {
return Particle(occurrenceRange: occurrence, term: cached.term)
}
let scoped = context.scoped(for: XSDNode.schemaOwner(definition))
guard let inner = modelGroup(in: definition, scoped.visiting(name)) else {
return nil
}
context.groupMemo.store[key] = inner
return Particle(occurrenceRange: occurrence, term: inner.term)
}

View File

@ -11,10 +11,14 @@ import Testing
/// both depths still exceed the old 4096-position cap, so the point holds.
@Suite("UPA on 2^K nested-group content models")
struct SchemaDeterminismGroupBlowupTests {
// The compiled content model also memoizes group expansion
// (GroupParticleMemo), so even these depths build in O(depth), not 2^depth: a
// depth past ~25 is infeasible to inline at all, so it doubles as a regression
// guard on that memo. WASI uses a smaller depth (~30x slower runtime).
#if os(WASI)
private static let depth = 13 // 2^13 = 8192 positions, past the old 4096 cap
private static let depth = 16 // 2^16 = 65536 inlined positions, past the old 4096 cap
#else
private static let depth = 20 // 2^20 = ~1,048,576 inlined positions
private static let depth = 40 // 2^40 inlined positions: infeasible without memoization
#endif
/// `g0=(g1,g1) … g{depth-1}=(g{depth},g{depth})`, leaf `g{depth}` = the given model.