From bc0638e201644e8ae0bc610bdee410651bca5a85 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 22 Jun 2026 23:12:11 +0200 Subject: [PATCH] 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 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. --- CHANGELOG.md | 2 ++ Sources/Schema/GroupParticleMemo.swift | 22 +++++++++++++++++++ Sources/Schema/XSDContext.swift | 1 + Sources/Schema/XSDParserParticles.swift | 9 ++++++++ Tests/SchemaDeterminismGroupBlowupTests.swift | 8 +++++-- 5 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 Sources/Schema/GroupParticleMemo.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 93607a0..f474b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Compiling a schema with deeply nested, multiply-referenced model groups is no longer exponential. `XSDParserParticles.groupReferenceParticle` inlined each `` into the compiled `Particle` content model, so `g0=(g1,g1), g1=(g2,g2), …` built `2^K` particles (measured: depth 14 = 0.21s, depth 20 ~13-18s, growing ~4x per two levels). The expansion is now memoized per (group definition, cycle-visiting set) in a per-compile `GroupParticleMemo`, so sibling references share it and the build drops to `O(groups)` (depth 40 now compiles in ~0.016s; previously infeasible). The shared content term is reused by value (copy-on-write), so the compiled model is byte-for-byte the same and validation is unchanged: the XSTS gate is unchanged (invalid-schemas-accepted 15, valid-schemas-rejected 0, instance buckets held) and the streaming-vs-tree fuzz differential still passes. This closes the schema-compile half of the interactive-safety group-blowup (the companion to the `positionCap` determinism fix); a regression is guarded by a depth-40 compile test. + - The Unique Particle Attribution (UPA / cos-nonambig) determinism check is now compositional and no longer caps or skips. `ContentModelDeterminism` delegates to a new `CompositionalDeterminism` engine that computes `first`/`followlast` over PARTICLE identities and summarizes each `` once, so a multiply-referenced nested group whose inlined Glushkov automaton would be `2^K` positions is checked in `O(nodes * particles^2)` time instead of being silently skipped. This DELETES `ContentModelDeterminism.positionCap` (the last named interactive-safety resource-bound blocker, production-readiness stopper #4) and the silent under-rejection it caused (stopper #3). The compositional engine was proven verdict-equivalent to the prior inlining position automaton by a differential over the entire XSTS corpus (zero divergences) before replacing it; the XSTS conformance gate is unchanged (invalid-schemas-accepted 15, valid-schemas-rejected 0), and a new test compiles a 2^20-position nested-group schema without hanging and rejects an ambiguous one the old cap would have skipped. The per-particle model (a particle never conflicts with a repetition of itself) and the QName-only overlap (no substitution-group expansion) are preserved, so the false-positive direction stays at zero. - Schema compile diagnostics are now located: `SchemaError.inconsistent` carries `[ValidationError]` with coding paths that resolve to `TreeNode.sourceRange` on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; `SchemaError.inconsistentFindings` exposes the full list. Schematron reuses the shared `validationCodingPath()` helper. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`. The derivation/cycle-pass checkers, which report a cycle by component name over a name-keyed graph, now record the declaring node alongside that graph and underline it: a type derived from itself underlines its `complexType`/`simpleType`, an element that is a member of its own substitution group underlines its `element`, a model group or attribute group that references itself underlines its `group`/`attributeGroup` declaration, and an all-group referenced inside a `sequence`/`choice`/`all` underlines the offending `group` reference. The content-derivation-pass checkers (the `cos-ct-extends`/`src-ct` base-kind family) follow: a complexContent extension adding element content over a simpleContent base, a simpleContent restriction whose base is a built-in or user simple type, an unfaithful inline type in a simpleContent restriction, a complexContent base that is a simple type, a simpleContent extension whose base is `xs:anyType`, and a mismatched `mixed` setting on a complexContent extension each underline their `extension`/`restriction` derivation node, and an element carrying a `default`/`fixed` value over element-only or empty content underlines the `element`. The reference-resolution rule is now genuinely located: `referenceFindings` previously wrapped its whole aggregate in `.unlocated`, so every undeclared-reference error collapsed onto the schema root despite the rule being dispatched as located; each of its six sub-collectors (the core type/base/itemType/memberTypes/ref/substitutionGroup walk, the `xs:`-namespace builtin check, the simpleContent base-kind check, the un-imported-namespace check, the redefine-component-existence check, and the import-namespace-agreement check) now attaches to its referencing node, `import`, or `redefine` component. The extension/substitution-pass checkers follow: an `all` group reached through an extension over a base with content underlines the `extension`, an anonymous complex-type restriction that is not a subset of its base underlines the `restriction`, and a substitution-group member whose type is not validly derived from its head underlines the member `element`. The names/UPA/notation-pass checkers follow: a duplicate global component name underlines the second declaration, a duplicate identity-constraint name underlines the second constraint, a `keyref` whose `refer` names no key/unique (or whose field arity differs) underlines the `keyref`, a Unique Particle Attribution violation underlines the ambiguous content-model group, and a notation missing both `public` and `system`, a notation with forbidden content, or a `NOTATION` enumeration naming no declared notation underline the `notation` or `enumeration`. The composition/redefine-pass checkers complete the burndown (every schema compile checker now reports through `SchemaLocatedFinding`, with no `.unlocated` fallback left): a resolved `schemaLocation` whose content is not a schema underlines the `include`/`import`/`redefine` reference (its node is captured at the load-failure site), a redefined type that does not restrict or extend itself underlines the redefined type, a redefined group or attribute group with a malformed self-reference underlines the offending reference or redefinition, and a redefined attribute group or model group that does not restrict its original underlines the redefinition. diff --git a/Sources/Schema/GroupParticleMemo.swift b/Sources/Schema/GroupParticleMemo.swift new file mode 100644 index 0000000..a0b0e3d --- /dev/null +++ b/Sources/Schema/GroupParticleMemo.swift @@ -0,0 +1,22 @@ +extension PureXML.Schema { + /// The key under which an `` 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 + } + + /// 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] = [:] + } +} diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index a01fda9..02c064d 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -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 diff --git a/Sources/Schema/XSDParserParticles.swift b/Sources/Schema/XSDParserParticles.swift index ff56a3e..7418f7b 100644 --- a/Sources/Schema/XSDParserParticles.swift +++ b/Sources/Schema/XSDParserParticles.swift @@ -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) } diff --git a/Tests/SchemaDeterminismGroupBlowupTests.swift b/Tests/SchemaDeterminismGroupBlowupTests.swift index 8232b81..81d1e1e 100644 --- a/Tests/SchemaDeterminismGroupBlowupTests.swift +++ b/Tests/SchemaDeterminismGroupBlowupTests.swift @@ -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.