glossarist 2.11.3 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3342996cc6138d6314fc904cc38790e593dfc6336943b421357406f78c62df9
4
- data.tar.gz: f385a95f0ee4cf4b9c1a927f9e63bb3dd6ba9bbbc468c5881011d49041b79a8c
3
+ metadata.gz: 4fafa61aa5261ca29216b423006cd0764470a9f8bf59733449ba3b501a63413b
4
+ data.tar.gz: b7c5f6148a76feca5dcbe8321e1a49bed99772a415168ddb977786d214c50110
5
5
  SHA512:
6
- metadata.gz: 39a3a8436356b5280bde95caff163f18d1e2654f4c00603d124dff135f242f27ca15e50664cf6a649b9be8718678723218b37ec9548ba9b5acae029bbe132b9d
7
- data.tar.gz: f01d59ac8056b432faeeb9addc329c080d2b4dcd6a8ab74941016d5325d0814c3dab77381682d5c6b570c61d38f70990af9c8d742c5538faa9e53d157643b087
6
+ metadata.gz: 163a2b94ff599127f8997de745f05ebec13ab19095790bf91ca832e7ea2c79525c65ca93a4e1cbd45ba4fcd9c4f1d4db9cb564bf370497d1e22fd5aa3e28ec73
7
+ data.tar.gz: 43be5caab946b3202614ec38db165f6ce72b56137ca1daa87fa0e9c12a853b3c878541c8b8a9b23589719d4239a566ddb621ddee2552e7c284ab435232c03cd1
data/CLAUDE.md CHANGED
@@ -35,6 +35,36 @@ All model classes use `Lutaml::Model::Serializable` for serialization.
35
35
  - **`ConceptReference`** (`concept_reference.rb`) — a typed reference to another concept. Local refs use `concept_id` alone; external refs use `source` (URN prefix) + `concept_id` or a direct `urn` field. Has `local?`/`external?` predicates. No `to_gcr_hash` or `from_urn` — model-driven architecture uses lutaml-model for serialization; URN construction/parsing belongs in `UrnResolver`.
36
36
  - **`RelatedConcept`** (`related_concept.rb`) — a concept with a typed relationship. Relationship types cover ISO 10241-1 (deprecates/supersedes/superseded_by/compare/contrast/see), ISO 25964/SKOS (broader/narrower/broader_generic/narrower_generic/broader_partitive/narrower_partitive/broader_instantial/narrower_instantial/equivalent/close_match/broad_match/narrow_match/related_match), ISO 12620/TBX (homograph/false_friend/related_concept/related_concept_broader/related_concept_narrower/sequentially_related_concept/spatially_related_concept/temporally_related_concept), and designation-level (abbreviated_form_for/short_form_for). Types are defined in `config.yml` under `related_concept.type`.
37
37
 
38
+ #### V3 `related` placement (MECE)
39
+
40
+ In V3, `related` lives **only** on `V3::ManagedConcept#related`.
41
+ `V3::ManagedConceptData` does NOT declare or serialize `related`.
42
+ V2 placed `related` inside data; the `SchemaMigration::V2ToV3#step_v2_to_v3`
43
+ migration moves any data-level entries up to the concept level. The
44
+ V3 output never carries `related` at the data level.
45
+
46
+ This consolidation closes the legacy trap where writing to
47
+ `data.related` bypassed `ManagedConcept.detect_schema_version` (which
48
+ keys off `concept.related`).
49
+
50
+ ### PartitiveHyperedge (V3 only)
51
+
52
+ `PartitiveHyperedge` (`v3/partitive_hyperedge.rb`) — a one-to-many partitive decomposition. One comprehensive concept is related to one or more parts as a SINGLE relationship. Captures invariants that binary `RelatedConcept` edges cannot:
53
+
54
+ - which comprehensive owns which parts (set membership)
55
+ - diagram notation flags (`PluralityMarker`: `double`, `dashed`)
56
+ - enumeration completeness (`PartitiveEnumeration`: `closed`, `open`)
57
+
58
+ Wired into `V3::ManagedConcept#partitive_hyperedges`. NOT on `ManagedConceptData` (relationships live at the concept level for MECE consistency with `related`).
59
+
60
+ Enum values are SSOT-loaded from `config.yml` via `GlossaryDefinition::PARTITIVE_ENUMERATION_VALUES` and `PLURALITY_MARKER_VALUES`. The `values:` option on each attribute documents the enum; the model's `initialize` override enforces them at construction (lutaml-model 0.8.17 does not enforce `values:` on assignment). Construction also rejects empty comprehensive, empty parts, self-loops, and duplicate marker values.
61
+
62
+ Semantic checks (defaulted-enumeration warning, defensive walk) live in `Validation::Rules::PartitiveHyperedgeRule` (auto-registered by `Validation::Rules`).
63
+
64
+ RDF emission: `Rdf::GlossHyperedge` view class (per-hyperedge `gloss:PartitiveHyperedge` subject with `gloss:comprehensive`, `gloss:part+`, `gloss:enumeration`, `gloss:hasPluralityMarker*`, `gloss:hyperedgeContent?`), wired into `Transforms::ConceptToGlossTransform` and emitted as `gloss:hasHyperedge` link from `GlossConcept`.
65
+
66
+ Binary `broader_partitive`/`narrower_partitive` `RelatedConcept` edges and `PartitiveHyperedge` coexist — no automatic consolidation. See `concept-model/TODO.hyperedge/00-design-overview.md`.
67
+
38
68
  ### Reference Resolution
39
69
 
40
70
  - **`ReferenceExtractor`** (`reference_extractor.rb`) — extracts `ConceptReference` and `AssetReference` objects from `{{...}}` mentions and `image::...[]` references in concept text fields.
data/config.yml CHANGED
@@ -186,3 +186,18 @@ concept_date:
186
186
  - retired
187
187
  - review
188
188
  - review_decision
189
+
190
+ partitive_enumeration:
191
+ # Whether a PartitiveHyperedge's parts array constitutes the complete
192
+ # decomposition of the comprehensive (closed) or only a partial one
193
+ # (open). Default: closed.
194
+ value:
195
+ - closed
196
+ - open
197
+
198
+ plurality_marker:
199
+ # Diagram notation flags from VIM concept-relationship diagrams.
200
+ # Orthogonal to enumeration — both may be set on the same hyperedge.
201
+ value:
202
+ - double # close-set double line at receiver; several parts involved
203
+ - dashed # broken/dashed line; plurality is uncertain
@@ -7,6 +7,7 @@ module Glossarist
7
7
  values: Glossarist::GlossaryDefinition::CONCEPT_SOURCE_TYPES
8
8
  attribute :origin, Citation
9
9
  attribute :modification, :string
10
+ attribute :sourced_from, Citation, collection: true
10
11
 
11
12
  key_value do
12
13
  map :id, to: :id
@@ -14,6 +15,7 @@ module Glossarist
14
15
  map :status, to: :status
15
16
  map :type, to: :type
16
17
  map :modification, to: :modification
18
+ map :sourced_from, to: :sourced_from
17
19
  end
18
20
  end
19
21
  end
@@ -3,7 +3,7 @@
3
3
  module Glossarist
4
4
  module Designation
5
5
  class DesignationRelationship < Lutaml::Model::Serializable
6
- attribute :content, :string
6
+ attribute :content, :hash
7
7
  attribute :type, :string,
8
8
  values: Glossarist::GlossaryDefinition::DESIGNATION_RELATIONSHIP_TYPES
9
9
  attribute :target, :string
@@ -33,5 +33,11 @@ module Glossarist
33
33
  "relationship_type")&.freeze
34
34
 
35
35
  ISO12620_TERM_TYPES = config.dig("iso12620", "term_type").freeze
36
+
37
+ PARTITIVE_ENUMERATION_VALUES =
38
+ config.dig("partitive_enumeration", "value").freeze
39
+
40
+ PLURALITY_MARKER_VALUES =
41
+ config.dig("plurality_marker", "value").freeze
36
42
  end
37
43
  end
@@ -174,6 +174,8 @@ module Glossarist
174
174
  return "3" if concept.related&.any?
175
175
  return "3" if concept.sources&.any?
176
176
  return "3" if concept.data&.domains&.any?
177
+ return "3" if concept.is_a?(V3::ManagedConcept) &&
178
+ concept.partitive_hyperedges&.any?
177
179
  return "3" if localization_has_references?(concept)
178
180
 
179
181
  "2"
@@ -11,6 +11,7 @@ module Glossarist
11
11
  attribute :sources, GlossConceptSource, collection: true
12
12
  attribute :domains, GlossConceptReference, collection: true
13
13
  attribute :dates, GlossConceptDate, collection: true
14
+ attribute :hyperedges, GlossHyperedge, collection: true
14
15
 
15
16
  RelationshipPredicates::CONCEPT_REL_PREDICATES.each_key do |type|
16
17
  attribute :"#{type}_targets", :string, collection: true
@@ -41,6 +42,8 @@ module Glossarist
41
42
  link: "gloss:hasDomain"
42
43
  members :dates,
43
44
  link: "gloss:hasDate"
45
+ members :hyperedges,
46
+ link: "gloss:hasPartitiveHyperedge"
44
47
 
45
48
  RelationshipPredicates::CONCEPT_REL_PREDICATES.each do |type, (ns, name)|
46
49
  predicate name, namespace: ns, to: :"#{type}_targets",
@@ -9,6 +9,7 @@ module Glossarist
9
9
  attribute :type, :string
10
10
  attribute :modification, :string
11
11
  attribute :origin, GlossCitation
12
+ attribute :sourced_from, GlossCitation, collection: true
12
13
 
13
14
  rdf do
14
15
  namespace Namespaces::GlossaristNamespace
@@ -25,6 +26,7 @@ module Glossarist
25
26
  to: :modification
26
27
 
27
28
  members :origin, link: "gloss:sourceOrigin"
29
+ members :sourced_from, link: "gloss:sourcedFrom"
28
30
  end
29
31
 
30
32
  def self.deterministic_id(source)
@@ -33,6 +35,9 @@ module Glossarist
33
35
  if origin
34
36
  parts << origin.source << origin.id << origin.version << origin.link
35
37
  end
38
+ Array(source.sourced_from).each do |sf|
39
+ parts << sf&.source << sf&.id << sf&.version << sf&.link
40
+ end
36
41
  Digest::MD5.hexdigest(parts.compact.join("|"))[0..11]
37
42
  end
38
43
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for a PartitiveHyperedge (concept-model v3.1.0).
8
+ #
9
+ # Emits one `gloss:PartitiveHyperedge` subject per hyperedge with:
10
+ #
11
+ # - gloss:comprehensive — IRI of the comprehensive concept
12
+ # - gloss:part — IRI of each part concept (zero or more)
13
+ # - gloss:enumeration — xsd:string ("closed" | "open")
14
+ # - gloss:hasPluralityMarker — xsd:string per marker ("double" |
15
+ # "dashed"). One triple per marker.
16
+ # - gloss:hyperedgeContent — xsd:string (omitted if absent)
17
+ #
18
+ # The hyperedge is anchored to the concept that carries it via
19
+ # `gloss:hasHyperedge` (an inverse of `gloss:comprehensive`). The
20
+ # subject URI is derived from the carrying concept's identifier
21
+ # and the comprehensive's id, so it is stable across builds.
22
+ class GlossHyperedge < Lutaml::Model::Serializable
23
+ attribute :identifier, :string
24
+ attribute :comprehensive_id, :string
25
+ attribute :comprehensive_uri, :string
26
+ attribute :part_uris, :string, collection: true
27
+ attribute :enumeration, :string
28
+ attribute :markers, :string, collection: true
29
+ attribute :content, :hash
30
+
31
+ rdf do
32
+ namespace Namespaces::GlossaristNamespace
33
+
34
+ subject { |h| "hyperedge/#{h.identifier}/#{h.comprehensive_id}" }
35
+
36
+ types "gloss:PartitiveHyperedge"
37
+
38
+ predicate :comprehensive, namespace: Namespaces::GlossaristNamespace,
39
+ to: :comprehensive_uri
40
+ predicate :part, namespace: Namespaces::GlossaristNamespace,
41
+ to: :part_uris
42
+ predicate :enumeration, namespace: Namespaces::GlossaristNamespace,
43
+ to: :enumeration
44
+ predicate :hasPluralityMarker,
45
+ namespace: Namespaces::GlossaristNamespace,
46
+ to: :markers
47
+ predicate :hyperedgeContent,
48
+ namespace: Namespaces::GlossaristNamespace,
49
+ to: :content
50
+ end
51
+ end
52
+ end
53
+ end
@@ -29,6 +29,7 @@ module Glossarist
29
29
  GlossFigureImage
30
30
  GlossTable
31
31
  GlossFormula
32
+ GlossHyperedge
32
33
  ].freeze
33
34
 
34
35
  VIEW_CLASS_NAMES.each do |name|
@@ -42,6 +42,7 @@ module Glossarist
42
42
  autoload :GlossFigureImage, "#{__dir__}/rdf/gloss_figure_image"
43
43
  autoload :GlossTable, "#{__dir__}/rdf/gloss_table"
44
44
  autoload :GlossFormula, "#{__dir__}/rdf/gloss_formula"
45
+ autoload :GlossHyperedge, "#{__dir__}/rdf/gloss_hyperedge"
45
46
  autoload :V3, "#{__dir__}/rdf/v3"
46
47
  end
47
48
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Glossarist
4
4
  class RelatedConcept < Lutaml::Model::Serializable
5
- attribute :content, :string
5
+ attribute :content, :hash
6
6
  attribute :type, :string,
7
7
  values: Glossarist::GlossaryDefinition::RELATED_CONCEPT_TYPES
8
8
  attribute :ref, ConceptRef
@@ -38,11 +38,25 @@ module Glossarist
38
38
  end
39
39
 
40
40
  def self.step_v2_to_v3(concept)
41
- if concept.data&.related&.any?
42
- concept.related ||= []
43
- concept.related = (concept.related + concept.data.related).uniq
44
- concept.data.related = []
45
- end
41
+ # V2 placed `related` inside ManagedConceptData; V3 places it
42
+ # on ManagedConcept itself. Move any data-level related entries
43
+ # up to the concept level. V3::ManagedConceptData no longer
44
+ # serializes `related`, so we don't need to clear the moved-from
45
+ # slot — the V3 output naturally omits it.
46
+ data_related = concept.data&.related
47
+ return step_v2_to_v3_hyperedge_note if data_related.nil? || data_related.empty?
48
+
49
+ concept.related ||= []
50
+ concept.related = (concept.related + data_related).uniq
51
+ "3"
52
+ end
53
+
54
+ # Hyperedges: V2 has no hyperedge concept. V2's binary
55
+ # `broader_partitive` / `narrower_partitive` edges migrate as
56
+ # `related` entries (see step_v2_to_v3). Hyperedges are a V3-only
57
+ # construct and require no migration logic — concepts upgraded
58
+ # from V2 to V3 simply have no hyperedges.
59
+ def self.step_v2_to_v3_hyperedge_note
46
60
  "3"
47
61
  end
48
62
  end
@@ -130,10 +130,44 @@ module Glossarist
130
130
  domains: build_gloss_domains(managed_concept.data&.domains,
131
131
  identifier),
132
132
  dates: build_gloss_dates(managed_concept.dates, identifier),
133
+ hyperedges: build_gloss_hyperedges(v3_hyperedges(managed_concept),
134
+ identifier),
133
135
  **rel_targets,
134
136
  )
135
137
  end
136
138
 
139
+ # Hyperedges are a V3-only attribute. The transform accepts both
140
+ # V1/V2 and V3 concepts; for V1/V2 concepts the hyperedge list
141
+ # is always empty.
142
+ def v3_hyperedges(managed_concept)
143
+ return [] unless managed_concept.is_a?(V3::ManagedConcept)
144
+
145
+ managed_concept.partitive_hyperedges
146
+ end
147
+
148
+ def build_gloss_hyperedges(hyperedges, identifier)
149
+ return [] unless hyperedges
150
+
151
+ Array(hyperedges).map do |he|
152
+ Rdf::GlossHyperedge.new(
153
+ identifier: identifier.to_s,
154
+ comprehensive_id: he.comprehensive.id.to_s,
155
+ comprehensive_uri: hyperedge_concept_uri(he.comprehensive),
156
+ part_uris: Array(he.parts).map { |p| hyperedge_concept_uri(p) },
157
+ enumeration: he.enumeration,
158
+ markers: Array(he.markers),
159
+ content: he.content,
160
+ )
161
+ end
162
+ end
163
+
164
+ def hyperedge_concept_uri(ref)
165
+ return nil unless ref.is_a?(Glossarist::ConceptRef)
166
+
167
+ Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
168
+ "concept/#{ref.id}"
169
+ end
170
+
137
171
  def build_gloss_localized_concept(l10n, concept_id)
138
172
  lang = l10n.language_code
139
173
  data = l10n.data
@@ -4,12 +4,14 @@ module Glossarist
4
4
  module V2
5
5
  class ConceptSource < Glossarist::ConceptSource
6
6
  attribute :origin, V2::Citation
7
+ attribute :sourced_from, V2::Citation, collection: true
7
8
 
8
9
  key_value do
9
10
  map :origin, to: :origin
10
11
  map :status, to: :status
11
12
  map :type, to: :type
12
13
  map :modification, to: :modification
14
+ map :sourced_from, to: :sourced_from
13
15
  end
14
16
  end
15
17
  end
@@ -4,12 +4,14 @@ module Glossarist
4
4
  module V3
5
5
  class ConceptSource < Glossarist::ConceptSource
6
6
  attribute :origin, V3::Citation
7
+ attribute :sourced_from, V3::Citation, collection: true
7
8
 
8
9
  key_value do
9
10
  map :origin, to: :origin
10
11
  map :status, to: :status
11
12
  map :type, to: :type
12
13
  map :modification, to: :modification
14
+ map :sourced_from, to: :sourced_from
13
15
  end
14
16
  end
15
17
  end
@@ -5,6 +5,7 @@ module Glossarist
5
5
  class ManagedConcept < Glossarist::ManagedConcept
6
6
  attribute :data, V3::ManagedConceptData, default: -> { V3::ManagedConceptData.new }
7
7
  attribute :related, V3::RelatedConcept, collection: true
8
+ attribute :partitive_hyperedges, V3::PartitiveHyperedge, collection: true
8
9
  attribute :dates, V3::ConceptDate, collection: true
9
10
  attribute :date_accepted, V3::ConceptDate
10
11
  attribute :sources, V3::ConceptSource, collection: true
@@ -12,6 +13,7 @@ module Glossarist
12
13
  key_value do
13
14
  map :data, to: :data
14
15
  map :related, to: :related
16
+ map :partitive_hyperedges, to: :partitive_hyperedges
15
17
  map :dates, to: :dates
16
18
  map %i[date_accepted dateAccepted],
17
19
  with: { from: :date_accepted_from_yaml, to: :date_accepted_to_yaml }
@@ -2,6 +2,20 @@
2
2
 
3
3
  module Glossarist
4
4
  module V3
5
+ # V3 ManagedConceptData — the data payload inside a V3::ManagedConcept.
6
+ #
7
+ # V3 placement rule (MECE): `related` lives ONLY on V3::ManagedConcept,
8
+ # not on its data payload. V2 placed `related` inside data; the
9
+ # V2→V3 migration (SchemaMigration::V2ToV3) moves it to the concept
10
+ # level. Keeping `related` writable on V3 data would re-open the
11
+ # trap where writing to `data.related` bypasses
12
+ # `ManagedConcept.detect_schema_version` (which keys off
13
+ # `concept.related`).
14
+ #
15
+ # The base class Glossarist::ManagedConceptData still declares
16
+ # `related` for V1/V2 compatibility; V3 overrides that with an
17
+ # empty attribute via `attribute :related, nil` so the slot is
18
+ # inert and serializes nothing.
5
19
  class ManagedConceptData < Glossarist::ManagedConceptData
6
20
  attribute :sources, V3::ConceptSource, collection: true
7
21
  attribute :localizations, V3::LocalizedConcept,
@@ -17,7 +31,6 @@ module Glossarist
17
31
  with: { from: :domains_from_yaml, to: :domains_to_yaml }
18
32
  map :tags, to: :tags
19
33
  map :sources, to: :sources
20
- map :related, to: :related
21
34
  map :localizations, to: :localizations,
22
35
  with: { from: :localizations_from_yaml, to: :localizations_to_yaml }
23
36
  end
@@ -34,3 +47,4 @@ module Glossarist
34
47
  end
35
48
  end
36
49
  end
50
+
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # PartitiveHyperedge — a one-to-many partitive decomposition.
6
+ #
7
+ # One comprehensive concept (the whole) is related to one or more
8
+ # parts as a SINGLE relationship. Captures invariants that binary
9
+ # RelatedConcept edges cannot:
10
+ #
11
+ # - which comprehensive owns which parts (set membership)
12
+ # - plurality markers from the source diagram (double / dashed)
13
+ # - enumeration completeness (closed: all parts listed; open:
14
+ # other parts may exist)
15
+ #
16
+ # Enum values are SSOT-loaded from `config.yml` via
17
+ # `GlossaryDefinition`. The `values:` option on each attribute
18
+ # documents the enum for schema generation. lutaml-model 0.8.x
19
+ # does NOT enforce `values:` on assignment — semantic checks live
20
+ # in `Validation::Rules::PartitiveHyperedgeRule` (auto-registered
21
+ # via `Validation::Rules`). The model's `validate!` method covers
22
+ # type-shape invariants that fail loudly at construction (empty
23
+ # comprehensive, empty parts, self-loop, invalid enum values).
24
+ #
25
+ # Note on duplicates: lutaml-model's enum-collection setter
26
+ # dedupes silently via the getter, so duplicate markers at
27
+ # construction time are silently coerced to a unique set. We do
28
+ # not attempt to override this; the framework's Set semantics are
29
+ # the data contract.
30
+ #
31
+ # `content` is a localized string hash `{ "eng" => "...", "fra" => "..." }`
32
+ # keyed by ISO 639 language code. All free-form text fields in
33
+ # Glossarist MUST be localized — the project is multi-language by
34
+ # domain. Read values via the `LocalizedString` helper module:
35
+ #
36
+ # Glossarist::LocalizedString.fetch(he.content, "eng")
37
+ #
38
+ # Matches the pattern used by `Section#names`, `DatasetRegister#description`,
39
+ # `Formula#expression`, `Table#content`. The concept-model schema and
40
+ # `RelatedConcept#content` still declare `type: string` today — those
41
+ # are bugs to fix, not patterns to copy.
42
+ class PartitiveHyperedge < Lutaml::Model::Serializable
43
+ DEFAULT_ENUMERATION = "closed"
44
+
45
+ attribute :comprehensive, ConceptRef
46
+ attribute :parts, ConceptRef, collection: true
47
+ attribute :enumeration, :string,
48
+ values: Glossarist::GlossaryDefinition::PARTITIVE_ENUMERATION_VALUES,
49
+ default: -> { DEFAULT_ENUMERATION }
50
+ attribute :markers, :string,
51
+ values: Glossarist::GlossaryDefinition::PLURALITY_MARKER_VALUES,
52
+ collection: true
53
+ attribute :content, :hash
54
+
55
+ key_value do
56
+ map :comprehensive, to: :comprehensive
57
+ map :parts, to: :parts
58
+ map :enumeration, to: :enumeration
59
+ map :markers, to: :markers
60
+ map :content, to: :content
61
+ end
62
+
63
+ # Type-shape validators — invoke via `validate!` after
64
+ # construction. We do NOT call these from `initialize` because
65
+ # lutaml-model deserializes attributes AFTER `new` returns (see
66
+ # `Lutaml::KeyValue::Transform#data_to_model`); validating in
67
+ # `initialize` sees a half-built instance and raises spuriously.
68
+
69
+ def validate!
70
+ validate_comprehensive!
71
+ validate_parts!
72
+ validate_self_loop!
73
+ validate_markers!
74
+ validate_enumeration!
75
+ self
76
+ end
77
+
78
+ private
79
+
80
+ def validate_comprehensive!
81
+ return if comprehensive.is_a?(ConceptRef) &&
82
+ (comprehensive.source || comprehensive.id)
83
+
84
+ raise ArgumentError,
85
+ "PartitiveHyperedge#comprehensive must be a non-empty " \
86
+ "ConceptRef (source or id required)"
87
+ end
88
+
89
+ def validate_parts!
90
+ return if Array(parts).any?
91
+
92
+ raise ArgumentError, "PartitiveHyperedge requires at least one part"
93
+ end
94
+
95
+ def validate_self_loop!
96
+ return unless comprehensive.is_a?(ConceptRef)
97
+
98
+ comp_key = [comprehensive.source, comprehensive.id]
99
+ parts.each do |p|
100
+ next unless p.is_a?(ConceptRef)
101
+ next unless [p.source, p.id] == comp_key
102
+
103
+ raise ArgumentError,
104
+ "PartitiveHyperedge#parts cannot include the comprehensive"
105
+ end
106
+ end
107
+
108
+ def validate_markers!
109
+ Array(markers).each do |m|
110
+ unless Glossarist::GlossaryDefinition::PLURALITY_MARKER_VALUES.include?(m)
111
+ raise ArgumentError,
112
+ "PartitiveHyperedge#markers contains invalid value #{m.inspect}; " \
113
+ "must be one of " \
114
+ "#{GlossaryDefinition::PLURALITY_MARKER_VALUES.join(', ')}"
115
+ end
116
+ end
117
+ end
118
+
119
+ def validate_enumeration!
120
+ return if enumeration.nil?
121
+
122
+ unless Glossarist::GlossaryDefinition::PARTITIVE_ENUMERATION_VALUES
123
+ .include?(enumeration)
124
+ raise ArgumentError,
125
+ "PartitiveHyperedge#enumeration has invalid value " \
126
+ "#{enumeration.inspect}; must be one of " \
127
+ "#{GlossaryDefinition::PARTITIVE_ENUMERATION_VALUES.join(', ')}"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
data/lib/glossarist/v3.rb CHANGED
@@ -9,6 +9,7 @@ module Glossarist
9
9
  autoload :DetailedDefinition, "glossarist/v3/detailed_definition"
10
10
  autoload :ConceptRef, "glossarist/v3/concept_ref"
11
11
  autoload :RelatedConcept, "glossarist/v3/related_concept"
12
+ autoload :PartitiveHyperedge, "glossarist/v3/partitive_hyperedge"
12
13
  autoload :ConceptData, "glossarist/v3/concept_data"
13
14
  autoload :LocalizedConcept, "glossarist/v3/localized_concept"
14
15
  autoload :ManagedConceptData, "glossarist/v3/managed_concept_data"
@@ -23,6 +24,7 @@ module Glossarist
23
24
  Configuration.register_model(LocalizedConcept, id: :localized_concept)
24
25
  Configuration.register_model(ConceptRef, id: :concept_ref)
25
26
  Configuration.register_model(RelatedConcept, id: :related_concept)
27
+ Configuration.register_model(PartitiveHyperedge, id: :partitive_hyperedge)
26
28
  Configuration.register_model(ManagedConceptData, id: :managed_concept_data)
27
29
  Configuration.register_model(ManagedConcept, id: :managed_concept)
28
30
  Configuration.register_model(ConceptDocument, id: :concept_document)
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module Validation
5
+ module Rules
6
+ # Validates semantic invariants of PartitiveHyperedge entries that
7
+ # the model constructor does NOT enforce. Specifically:
8
+ #
9
+ # - warning when `enumeration` was filled by lutaml-model's
10
+ # default proc (i.e. the source YAML omitted it) — author is
11
+ # *encouraged* to set explicitly per the design doc
12
+ # - error when markers contain values not in
13
+ # PLURALITY_MARKER_VALUES
14
+ #
15
+ # The model constructor already rejects empty comprehensive,
16
+ # empty parts, self-loops, and (lutaml-model auto-dedupes
17
+ # duplicate markers on enum-collection assignment, so no
18
+ # explicit check is needed).
19
+ class PartitiveHyperedgeRule < Base
20
+ def code = "GLS-220"
21
+ def category = :schema
22
+ def severity = "error"
23
+ def scope = :concept
24
+
25
+ def applicable?(context)
26
+ concept = context.concept
27
+ return false unless concept.is_a?(V3::ManagedConcept)
28
+
29
+ concept.partitive_hyperedges&.any?
30
+ end
31
+
32
+ def check(context)
33
+ concept = context.concept
34
+ fname = context.file_name
35
+ issues = []
36
+
37
+ return issues unless concept.is_a?(V3::ManagedConcept)
38
+
39
+ concept.partitive_hyperedges.each_with_index do |he, idx|
40
+ check_comprehensive(he, idx, fname, issues)
41
+ check_parts(he, idx, fname, issues)
42
+ check_markers(he, idx, fname, issues)
43
+ check_enumeration_explicit(he, idx, fname, issues)
44
+ end
45
+
46
+ issues
47
+ end
48
+
49
+ private
50
+
51
+ def check_comprehensive(he, idx, fname, issues)
52
+ ref = he.comprehensive
53
+ return if ref.is_a?(Glossarist::ConceptRef) && (ref.source || ref.id)
54
+
55
+ issues << issue(
56
+ "partitive_hyperedge #{idx + 1} has empty comprehensive",
57
+ location: fname,
58
+ )
59
+ end
60
+
61
+ def check_parts(he, idx, fname, issues)
62
+ parts = Array(he.parts)
63
+ if parts.empty?
64
+ issues << issue(
65
+ "partitive_hyperedge #{idx + 1} has no parts",
66
+ location: fname,
67
+ )
68
+ return
69
+ end
70
+
71
+ parts.each_with_index do |part, pi|
72
+ next if part.is_a?(Glossarist::ConceptRef) && (part.source || part.id)
73
+
74
+ issues << issue(
75
+ "partitive_hyperedge #{idx + 1} part #{pi + 1} has empty ref",
76
+ location: fname,
77
+ )
78
+ end
79
+ end
80
+
81
+ def check_markers(he, idx, fname, issues)
82
+ # `using_default?` reports whether an attribute was set by
83
+ # the user (false) or filled by the default proc (true).
84
+ # `markers` has no default, so absence yields nil/[] and
85
+ # `using_default?(:markers)` is true. We still walk for
86
+ # invalid values because `values:` is informational.
87
+ Array(he.markers).each_with_index do |m, mi|
88
+ unless Glossarist::GlossaryDefinition::PLURALITY_MARKER_VALUES.include?(m)
89
+ issues << issue(
90
+ "partitive_hyperedge #{idx + 1} marker #{mi + 1} has invalid value #{m.inspect}",
91
+ location: fname,
92
+ )
93
+ end
94
+ end
95
+ end
96
+
97
+ def check_enumeration_explicit(he, idx, fname, issues)
98
+ # `using_default?(:enumeration)` is true iff the value was
99
+ # NOT explicitly set by the caller (filled by default proc).
100
+ return unless he.using_default?(:enumeration)
101
+
102
+ issues << issue(
103
+ "partitive_hyperedge #{idx + 1} has implicit enumeration (default '#{Glossarist::V3::PartitiveHyperedge::DEFAULT_ENUMERATION}')",
104
+ severity: "warning",
105
+ location: fname,
106
+ suggestion: "Set 'enumeration: closed' or 'enumeration: open' explicitly",
107
+ )
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -4,5 +4,5 @@
4
4
  #
5
5
 
6
6
  module Glossarist
7
- VERSION = "2.11.3"
7
+ VERSION = "2.12.1"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glossarist
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.3
4
+ version: 2.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-08 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -307,6 +307,7 @@ files:
307
307
  - lib/glossarist/rdf/gloss_figure_image.rb
308
308
  - lib/glossarist/rdf/gloss_formula.rb
309
309
  - lib/glossarist/rdf/gloss_grammar_info.rb
310
+ - lib/glossarist/rdf/gloss_hyperedge.rb
310
311
  - lib/glossarist/rdf/gloss_locality.rb
311
312
  - lib/glossarist/rdf/gloss_localized_concept.rb
312
313
  - lib/glossarist/rdf/gloss_non_verbal_rep.rb
@@ -394,6 +395,7 @@ files:
394
395
  - lib/glossarist/v3/localized_concept.rb
395
396
  - lib/glossarist/v3/managed_concept.rb
396
397
  - lib/glossarist/v3/managed_concept_data.rb
398
+ - lib/glossarist/v3/partitive_hyperedge.rb
397
399
  - lib/glossarist/v3/related_concept.rb
398
400
  - lib/glossarist/validation.rb
399
401
  - lib/glossarist/validation/asset_index.rb
@@ -436,6 +438,7 @@ files:
436
438
  - lib/glossarist/validation/rules/orphaned_bibliography_rule.rb
437
439
  - lib/glossarist/validation/rules/orphaned_images_rule.rb
438
440
  - lib/glossarist/validation/rules/orphaned_l10n_files_rule.rb
441
+ - lib/glossarist/validation/rules/partitive_hyperedge_rule.rb
439
442
  - lib/glossarist/validation/rules/preferred_term_rule.rb
440
443
  - lib/glossarist/validation/rules/ref_shape_rule.rb
441
444
  - lib/glossarist/validation/rules/registry.rb