feat(schema): carry a stable group-definition identity on shared expansions (#363)

Step 1 of the GSS content-matcher build (docs/design/counted-content-automaton.md).
A multiply-referenced `<xs:group>` expands to one copy-on-write-shared `Particle`
(GroupParticleMemo), but `Group`/`Term` are value types with no exposed identity,
so a content-model compiler cannot tell two references apart and inlines the group
per reference (the 2^K build the GSS matcher will close).

Group now carries a `definitionID: Int?`, stamped from a per-compile counter on
GroupParticleMemo when an expansion is first compiled, so every reference sharing
the memo key reuses the same stamped term and distinct definitions differ. It is
metadata only: Group is not Equatable/Hashable, so it perturbs no determinism or
restriction comparison, and it is nil for an inline group.

FP-neutral by construction (no consumer yet). Full in-repo suite green; the XSTS
buckets are unchanged (valid-schemas-rejected 0, invalid-schemas-accepted 8,
valid-instances-rejected 0, invalid-instances-accepted 14); swiftformat/swiftlint
clean. Step 2 (a call/return content-model build keyed on this id, alongside the
inlining oracle) consumes it.
This commit is contained in:
Mihaela Mihaljevic 2026-06-28 01:53:58 +02:00 committed by GitHub
parent 116b260149
commit e81e4b4df6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 38 additions and 3 deletions

View File

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- A model `Group` now carries an internal `definitionID`, a stable identity stamped on each `<xs:group>` expansion (from the existing per-compile memo key) and shared by every reference to it. It is metadata only (`Group` is not `Equatable`/`Hashable`, so it perturbs no comparison) and has no consumer yet: it is the first step of replacing the per-instance content-model build's per-reference group inlining (the `2^K` doubling-chain hang) with the graph-structured-stack matcher designed in `docs/design/counted-content-automaton.md`. No behaviour change; the XSTS buckets are unchanged.
- The schema-validity differential harness (`XSTSDifferentialTests`, the opt-in libxml2 characterisation for production-readiness stopper 3) now reports each disagreeing schema by its path relative to the corpus root, not its bare basename. The same basename recurs across subdirectories (`Facets/Schemas/gMonth_maxInclusive001.xsd`, which carries `value=""` and is correctly rejected, versus `Facets/gMonth/gMonth_maxInclusive001.xsd`, which carries the contested 2001-REC `--MM--` form), so a basename-only entry hid which file actually disagrees. The header now also records the triage of the "libxml2 stricter" queue: it is dominated by libxml2 over-strictness (a `maxOccurs` like `999999999999999` is a valid unbounded-precision integer libxml2 rejects on its machine-int limit; an unresolvable `include`/`import` it fails to load) and contested datatype-version readings (`gMonth --MM--`, accepted to keep valid XSTS instances; `anyURI` strictness), not clean false negatives. Characterisation only; no shipped behaviour changes.
### Fixed

View File

@ -250,10 +250,20 @@ public extension PureXML.Schema {
struct Group: Sendable {
public var compositor: Compositor
public var particles: [Particle]
/// A stable identity shared by every reference that expands to this same
/// `<xs:group>` definition (in the same scope), assigned when the expansion
/// is first compiled. It lets a content-model compiler emit a multiply
/// referenced group once instead of inlining it per reference (the `2^K`
/// build the design doc closes with a graph-structured-stack matcher). It
/// is metadata only: `Group` is not `Equatable`/`Hashable`, so carrying it
/// changes no comparison, and it is `nil` for an inline (non-referenced)
/// group.
public var definitionID: Int?
public init(compositor: Compositor, particles: [Particle]) {
public init(compositor: Compositor, particles: [Particle], definitionID: Int? = nil) {
self.compositor = compositor
self.particles = particles
self.definitionID = definitionID
}
}

View File

@ -18,5 +18,14 @@ extension PureXML.Schema {
/// instance per compile.
final class GroupParticleMemo {
var store: [GroupParticleMemoKey: PureXML.Schema.Particle] = [:]
/// Source of the stable `Group.definitionID` stamped on each distinct
/// expansion as it is first compiled, so every reference sharing the
/// expansion carries the same id and distinct definitions differ.
private var nextID = 0
func allocateDefinitionID() -> Int {
defer { nextID += 1 }
return nextID
}
}
}

View File

@ -110,8 +110,22 @@ extension PureXML.Schema.XSDParser {
guard let inner = modelGroup(in: definition, scoped.visiting(name)) else {
return nil
}
context.groupMemo.store[key] = inner
return Particle(occurrenceRange: occurrence, term: inner.term)
// Stamp the expansion with a stable identity so a content-model compiler
// can emit this group once rather than inline it per reference; every
// reference sharing this memo key reuses the stamped term.
let stamped = withDefinitionID(context.groupMemo.allocateDefinitionID(), on: inner)
context.groupMemo.store[key] = stamped
return Particle(occurrenceRange: occurrence, term: stamped.term)
}
/// `particle` with its model-group term carrying `id`, or unchanged when the
/// term is not a group (a group reference always expands to a model group, so
/// this only no-ops defensively).
private static func withDefinitionID(_ id: Int, on particle: Particle) -> Particle {
guard case let .group(group) = particle.term else { return particle }
var stamped = group
stamped.definitionID = id
return Particle(occurrenceRange: particle.occurrenceRange, term: .group(stamped))
}
/// The particle for an `<xs:element ref="...">`. An abstract head may not appear