glossarist 2.12.0 → 2.13.0

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: 4895d3a9f92f0c592e14932542e717c86d6c3b4559eec4ae133f056b99aeaae5
4
- data.tar.gz: 0aeac1eb4550d81deba20330d9744b73144fbfb50f28e01aec1f55af9410ee9a
3
+ metadata.gz: 85ff1a35136e9b7ff920830b2bf9a2e4b477bffed405c2386e33739ed6560a45
4
+ data.tar.gz: f7ef8fb96a58eb21a71534fcc8afc1cf7f26053379cfb00e5ec98098620ca6a6
5
5
  SHA512:
6
- metadata.gz: edd2a440b12a2423a5725036568b85734017e400727db62f166097d71cffaef29abe588ceef136f5b25b06f9904fabafd4500fe6cf461416736b3d02e2ca1b06
7
- data.tar.gz: ef423629a5f612d5fe6d3d84323d2acc5ae0a5a0aff9903578ac72f3dae01e997a60104d8b8f5e5bb55e7c2c667c3f9753274d804fff9913cb0fd7deed694da0
6
+ metadata.gz: beeecf5f816d5ec10741375d357a1b3602e7d415ebedfc71eff52d414f1d623ed119c7995355231c147020f54b814dc8306d37acdc6eecacdf8ca72ec6ddd65f
7
+ data.tar.gz: 0a7de5bd20f0f7c58c1c08066983b5c490999570249b686f84f60bd87df9b5e436913db66e43b5d1003805bfa33a6613d67bbd8c49373d21a03815dde83a8e42
data/CLAUDE.md CHANGED
@@ -35,6 +35,48 @@ 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
+ ### PartitiveRelation (V3 only)
51
+
52
+ `PartitiveRelation` (`v3/partitive_relation.rb`) — an ISO 704 / ISO 1087-1 / ISO 12620 partitive relation. One comprehensive concept (superordinate concept partitive) is connected to two or more partitive concepts (subordinate concepts partitive) which fitted together constitute the comprehensive.
53
+
54
+ Each PartitiveRelation carries:
55
+
56
+ - `comprehensive` — ConceptRef to the superordinate concept partitive (the whole)
57
+ - `partitives` — `PartitiveMember [2..*]` (ISO 704 "two or more"); each member wraps a ConceptRef plus optional `MemberCertainty` (`confirmed` default, `possible`)
58
+ - `completeness` — `complete` (default; the encoded partitives constitute the whole) or `partial` (others exist beyond those encoded). ISO 704 rake backline notation.
59
+ - `plurality` — optional `TypeSharedPlurality` block (ISO 704 close-set double line promoted to data: `is_shared`, `is_uncertain`, `shared_type`). Replaces the prior opaque `markers` field.
60
+ - `criterion` — optional localized string hash (`{ "eng" => "physical structure" }`). ISO 12620 coordinate-concept coherence: partitives within one relation share the comprehensive AND the criterion.
61
+
62
+ Wired into `V3::ManagedConcept#partitive_relations`. NOT on `ManagedConceptData` (relationships live at the concept level for MECE consistency with `related`).
63
+
64
+ Enum values are SSOT-loaded from `config.yml` via `GlossaryDefinition::COMPLETENESS_VALUES` and `MEMBER_CERTAINTY_VALUES`. The `values:` option on each attribute documents the enum; the model's `validate!` method enforces structural invariants after construction (empty comprehensive, <2 partitives, self-loops, incoherent plurality, invalid enum values).
65
+
66
+ Semantic checks (≥2 partitives cardinality, duplicate comprehensive+criterion detection, criterion-absent warning, ExternalConcept shape) live in `Validation::Rules::PartitiveRelationRule` (auto-registered by `Validation::Rules`, code GLS-221).
67
+
68
+ RDF emission: `Rdf::GlossPartitiveRelation` view class with companion `GlossPartitiveMember` and `GlossTypeSharedPlurality`. Wired into `Transforms::ConceptToGlossTransform` and emitted as `gloss:hasPartitiveRelation` link from `GlossConcept`.
69
+
70
+ Binary `broader_partitive`/`narrower_partitive`/`has_part`/`is_part_of` `RelatedConcept` edges and `PartitiveRelation` coexist — no automatic consolidation. Use a binary edge for pairwise relationships; use a PartitiveRelation when membership is jointly significant (≥2 partitives + completeness/plurality/criterion metadata).
71
+
72
+ #### ExternalConcept
73
+
74
+ `status: external` is a value of `ConceptStatus` (not a separate class). A concept with `status: external` is referenced from this dataset but defined elsewhere — ISO 704 "parenthetic term" semantic role. Required fields (definition, sources) are relaxed; at least one designation is still required. Resolves to another dataset's concept via `provided_by` when a defining dataset is loaded.
75
+
76
+ #### Relationship type extensions
77
+
78
+ Two new values in `RelatedConceptType`: `provides` (real concept → external concept) and `provided_by` (external concept → real concept). Inverse pair, established at the collection level.
79
+
38
80
  ### Reference Resolution
39
81
 
40
82
  - **`ReferenceExtractor`** (`reference_extractor.rb`) — extracts `ConceptReference` and `AssetReference` objects from `{{...}}` mentions and `image::...[]` references in concept text fields.
data/config.yml CHANGED
@@ -101,6 +101,12 @@ related_concept:
101
101
  - version_of
102
102
  - current_version
103
103
  - current_version_of
104
+ # ExternalConcept resolution (Glossarist extension)
105
+ # Real concept `provides` substance for an external concept;
106
+ # external concept `provided_by` real concept. Inverse pair.
107
+ # Established at the collection level when both datasets are loaded.
108
+ - provides
109
+ - provided_by
104
110
 
105
111
  iso12620:
106
112
  term_type:
@@ -178,6 +184,10 @@ concept:
178
184
  - valid
179
185
  - superseded
180
186
  - retired
187
+ # Glossarist extension: concept referenced from this dataset but
188
+ # defined elsewhere. Resolves via `provided_by` when a defining
189
+ # dataset is loaded. ISO 704 "parenthetic term" semantic role.
190
+ - external
181
191
 
182
192
  concept_date:
183
193
  type:
@@ -186,3 +196,24 @@ concept_date:
186
196
  - retired
187
197
  - review
188
198
  - review_decision
199
+
200
+ # PartitiveRelation redesign (TODO.partitive-relation-v2)
201
+ # Replaces partitive_enumeration + plurality_marker.
202
+
203
+ completeness:
204
+ # Whether the encoded partitives fitted together constitute the
205
+ # whole comprehensive concept (complete) or only some of it
206
+ # (partial — other partitives exist but are not encoded).
207
+ # ISO 704: rake backline ending with a tooth = complete;
208
+ # backline continuing without a tooth = partial.
209
+ # Default: complete.
210
+ value:
211
+ - complete
212
+ - partial
213
+
214
+ member_certainty:
215
+ # Per-partitive certainty. Glossarist extension beyond ISO 704
216
+ # notation. Defaults to confirmed when omitted.
217
+ value:
218
+ - confirmed
219
+ - possible
@@ -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
+ # PartitiveRelation redesign (TODO.partitive-relation-v2).
38
+ # Replaces PARTITIVE_ENUMERATION_VALUES + PLURALITY_MARKER_VALUES.
39
+ COMPLETENESS_VALUES = config.dig("completeness", "value").freeze
40
+
41
+ MEMBER_CERTAINTY_VALUES = config.dig("member_certainty", "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_relations&.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 :partitive_relations, GlossPartitiveRelation, 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 :partitive_relations,
46
+ link: "gloss:hasPartitiveRelation"
44
47
 
45
48
  RelationshipPredicates::CONCEPT_REL_PREDICATES.each do |type, (ns, name)|
46
49
  predicate name, namespace: ns, to: :"#{type}_targets",
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for V3::PartitiveMember. Emits a
8
+ # gloss:PartitiveMember subject with ref + certainty properties.
9
+ class GlossPartitiveMember < Lutaml::Model::Serializable
10
+ attribute :ref_id, :string
11
+ attribute :ref_source, :string
12
+ attribute :ref_text, :string
13
+ attribute :certainty, :string
14
+
15
+ rdf do
16
+ namespace Namespaces::GlossaristNamespace
17
+
18
+ subject { |m| "partitiveMember/#{m.ref_source}:#{m.ref_id}" }
19
+
20
+ types "gloss:PartitiveMember"
21
+
22
+ predicate :refSource, namespace: Namespaces::GlossaristNamespace,
23
+ to: :ref_source
24
+ predicate :refId, namespace: Namespaces::GlossaristNamespace,
25
+ to: :ref_id
26
+ predicate :refText, namespace: Namespaces::GlossaristNamespace,
27
+ to: :ref_text
28
+ predicate :certainty, namespace: Namespaces::GlossaristNamespace,
29
+ to: :certainty
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for V3::PartitiveRelation. Emits a
8
+ # gloss:PartitiveRelation subject with comprehensive, hasPartitive,
9
+ # completeness, hasPlurality, criterion properties.
10
+ class GlossPartitiveRelation < Lutaml::Model::Serializable
11
+ attribute :identifier, :string
12
+ attribute :comprehensive_uri, :string
13
+ attribute :partitive_member_ids, :string, collection: true
14
+ attribute :completeness, :string
15
+ attribute :has_plurality, GlossTypeSharedPlurality
16
+ attribute :criterion, :hash
17
+
18
+ rdf do
19
+ namespace Namespaces::GlossaristNamespace
20
+
21
+ subject { |r| "partitiveRelation/#{r.identifier}/#{r.object_id}" }
22
+
23
+ types "gloss:PartitiveRelation"
24
+
25
+ predicate :comprehensive, namespace: Namespaces::GlossaristNamespace,
26
+ to: :comprehensive_uri, uri_reference: true
27
+ predicate :hasPartitive, namespace: Namespaces::GlossaristNamespace,
28
+ to: :partitive_member_ids, uri_reference: true
29
+ predicate :completeness, namespace: Namespaces::GlossaristNamespace,
30
+ to: :completeness, uri_reference: true
31
+ members :has_plurality,
32
+ link: "gloss:hasPlurality"
33
+ predicate :criterion, namespace: Namespaces::GlossaristNamespace,
34
+ to: :criterion
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for V3::TypeSharedPlurality. Emits a
8
+ # gloss:TypeSharedPlurality subject with is_shared, is_uncertain,
9
+ # shared_type properties.
10
+ class GlossTypeSharedPlurality < Lutaml::Model::Serializable
11
+ attribute :is_shared, :boolean
12
+ attribute :is_uncertain, :boolean
13
+ attribute :shared_type_uri, :string
14
+
15
+ rdf do
16
+ namespace Namespaces::GlossaristNamespace
17
+
18
+ subject { |p| "plurality/#{p.object_id}" }
19
+
20
+ types "gloss:TypeSharedPlurality"
21
+
22
+ predicate :isShared, namespace: Namespaces::GlossaristNamespace,
23
+ to: :is_shared
24
+ predicate :isUncertain, namespace: Namespaces::GlossaristNamespace,
25
+ to: :is_uncertain
26
+ predicate :sharedType, namespace: Namespaces::GlossaristNamespace,
27
+ to: :shared_type_uri, uri_reference: true
28
+ end
29
+ end
30
+ end
31
+ end
@@ -29,6 +29,9 @@ module Glossarist
29
29
  GlossFigureImage
30
30
  GlossTable
31
31
  GlossFormula
32
+ GlossPartitiveRelation
33
+ GlossPartitiveMember
34
+ GlossTypeSharedPlurality
32
35
  ].freeze
33
36
 
34
37
  VIEW_CLASS_NAMES.each do |name|
@@ -42,6 +42,10 @@ 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 :GlossPartitiveRelation, "#{__dir__}/rdf/gloss_partitive_relation"
46
+ autoload :GlossPartitiveMember, "#{__dir__}/rdf/gloss_partitive_member"
47
+ autoload :GlossTypeSharedPlurality,
48
+ "#{__dir__}/rdf/gloss_type_shared_plurality"
45
49
  autoload :V3, "#{__dir__}/rdf/v3"
46
50
  end
47
51
  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,66 @@ 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
+ partitive_relations: build_gloss_partitive_relations(
134
+ v3_partitive_relations(managed_concept), identifier,
135
+ ),
133
136
  **rel_targets,
134
137
  )
135
138
  end
136
139
 
140
+ # PartitiveRelations are a V3-only attribute. The transform
141
+ # accepts both V1/V2 and V3 concepts; for V1/V2 concepts the
142
+ # relation list is always empty.
143
+ def v3_partitive_relations(managed_concept)
144
+ return [] unless managed_concept.is_a?(V3::ManagedConcept)
145
+
146
+ managed_concept.partitive_relations
147
+ end
148
+
149
+ def build_gloss_partitive_relations(relations, identifier)
150
+ return [] unless relations
151
+
152
+ Array(relations).map do |rel|
153
+ Rdf::GlossPartitiveRelation.new(
154
+ identifier: identifier.to_s,
155
+ comprehensive_uri: partitive_concept_uri(rel.comprehensive),
156
+ partitive_member_ids: Array(rel.partitives).map do |m|
157
+ partitive_member_uri(m)
158
+ end,
159
+ completeness: rel.completeness,
160
+ has_plurality: build_gloss_plurality(rel.plurality),
161
+ criterion: rel.criterion,
162
+ )
163
+ end
164
+ end
165
+
166
+ def build_gloss_plurality(plurality)
167
+ return nil unless plurality
168
+
169
+ Rdf::GlossTypeSharedPlurality.new(
170
+ is_shared: plurality.is_shared,
171
+ is_uncertain: plurality.is_uncertain,
172
+ shared_type_uri: partitive_concept_uri(plurality.shared_type),
173
+ )
174
+ end
175
+
176
+ def partitive_concept_uri(ref)
177
+ return nil unless ref.is_a?(Glossarist::ConceptRef)
178
+
179
+ Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
180
+ "concept/#{ref.id || ref.text}"
181
+ end
182
+
183
+ def partitive_member_uri(member)
184
+ return nil unless member.is_a?(V3::PartitiveMember)
185
+
186
+ ref = member.ref
187
+ return nil unless ref.is_a?(Glossarist::ConceptRef)
188
+
189
+ Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
190
+ "concept/#{ref.id || ref.text}"
191
+ end
192
+
137
193
  def build_gloss_localized_concept(l10n, concept_id)
138
194
  lang = l10n.language_code
139
195
  data = l10n.data
@@ -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_relations, V3::PartitiveRelation, 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_relations, to: :partitive_relations
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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # PartitiveMember — one member of a PartitiveRelation, carrying
6
+ # a ConceptRef to the partitive concept plus optional certainty
7
+ # metadata (Glossarist extension beyond ISO 704 notation).
8
+ #
9
+ # A PartitiveMember with no certainty field is implicitly
10
+ # confirmed.
11
+ class PartitiveMember < Lutaml::Model::Serializable
12
+ DEFAULT_CERTAINTY = "confirmed"
13
+
14
+ attribute :ref, ConceptRef
15
+ attribute :certainty, :string,
16
+ values: Glossarist::GlossaryDefinition::MEMBER_CERTAINTY_VALUES,
17
+ default: -> { DEFAULT_CERTAINTY }
18
+
19
+ key_value do
20
+ map :ref, to: :ref
21
+ map :certainty, to: :certainty
22
+ end
23
+
24
+ def validate!
25
+ validate_ref!
26
+ validate_certainty!
27
+ self
28
+ end
29
+
30
+ def confirmed?
31
+ certainty == DEFAULT_CERTAINTY
32
+ end
33
+
34
+ def possible?
35
+ certainty == "possible"
36
+ end
37
+
38
+ private
39
+
40
+ def validate_ref!
41
+ return if ref.is_a?(ConceptRef) && (ref.source || ref.id || ref.text)
42
+
43
+ raise ArgumentError,
44
+ "PartitiveMember#ref must be a non-empty ConceptRef " \
45
+ "(source, id, or text required)"
46
+ end
47
+
48
+ def validate_certainty!
49
+ return if certainty.nil?
50
+
51
+ unless Glossarist::GlossaryDefinition::MEMBER_CERTAINTY_VALUES
52
+ .include?(certainty)
53
+ raise ArgumentError,
54
+ "PartitiveMember#certainty has invalid value " \
55
+ "#{certainty.inspect}; must be one of " \
56
+ "#{GlossaryDefinition::MEMBER_CERTAINTY_VALUES.join(', ')}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # PartitiveRelation — an ISO 704 / ISO 1087-1 / ISO 12620
6
+ # partitive relation connecting a comprehensive concept
7
+ # (superordinate concept partitive) to two or more partitive
8
+ # concepts (subordinate concepts partitive) which fitted together
9
+ # constitute the comprehensive.
10
+ #
11
+ # Shown as a rake or bracket in source diagrams. All partitives
12
+ # within one relation are coordinate concepts: they share the
13
+ # comprehensive AND share the criterion of subdivision.
14
+ #
15
+ # Glossarist extensions beyond ISO notation:
16
+ # - Per-partitive certainty (MemberCertainty)
17
+ # - plurality.shared_type (the type the partitives share)
18
+ # - criterion as a structured field (not just diagrammatic)
19
+ #
20
+ # Replaces the prior PartitiveHyperedge class. The "hyperedge"
21
+ # framing was graph-theoretic; ISO calls this a *relation*.
22
+ class PartitiveRelation < Lutaml::Model::Serializable
23
+ DEFAULT_COMPLETENESS = "complete"
24
+
25
+ attribute :comprehensive, ConceptRef
26
+ attribute :partitives, PartitiveMember, collection: true
27
+ attribute :completeness, :string,
28
+ values: Glossarist::GlossaryDefinition::COMPLETENESS_VALUES,
29
+ default: -> { DEFAULT_COMPLETENESS }
30
+ attribute :plurality, TypeSharedPlurality
31
+ attribute :criterion, :hash
32
+
33
+ key_value do
34
+ map :comprehensive, to: :comprehensive
35
+ map :partitives, to: :partitives
36
+ map :completeness, to: :completeness
37
+ map :plurality, to: :plurality
38
+ map :criterion, to: :criterion
39
+ end
40
+
41
+ def validate!
42
+ validate_comprehensive!
43
+ validate_partitives!
44
+ validate_self_loop!
45
+ validate_completeness!
46
+ validate_plurality!
47
+ self
48
+ end
49
+
50
+ def complete?
51
+ completeness == "complete"
52
+ end
53
+
54
+ def partial?
55
+ completeness == "partial"
56
+ end
57
+
58
+ # ISO 704: a partitive relation connects to two or more
59
+ # partitives. A single binary has_part edge is not a
60
+ # PartitiveRelation.
61
+ def coordinate?
62
+ partitives.length >= 2
63
+ end
64
+
65
+ private
66
+
67
+ def validate_comprehensive!
68
+ return if comprehensive.is_a?(ConceptRef) &&
69
+ (comprehensive.source || comprehensive.id || comprehensive.text)
70
+
71
+ raise ArgumentError,
72
+ "PartitiveRelation#comprehensive must be a non-empty " \
73
+ "ConceptRef (source, id, or text required)"
74
+ end
75
+
76
+ def validate_partitives!
77
+ if partitives.empty?
78
+ raise ArgumentError, "PartitiveRelation requires at least one partitive"
79
+ end
80
+ unless coordinate?
81
+ raise ArgumentError,
82
+ "PartitiveRelation requires ≥2 partitives (ISO 704); a single " \
83
+ "binary has_part edge should be used instead"
84
+ end
85
+
86
+ partitives.each(&:validate!)
87
+ end
88
+
89
+ def validate_self_loop!
90
+ return unless comprehensive.is_a?(ConceptRef)
91
+
92
+ comp_key = [comprehensive.source, comprehensive.id]
93
+ partitives.each do |member|
94
+ next unless member.ref.is_a?(ConceptRef)
95
+ next unless [member.ref.source, member.ref.id] == comp_key
96
+
97
+ raise ArgumentError,
98
+ "PartitiveRelation#partitives cannot include the comprehensive"
99
+ end
100
+ end
101
+
102
+ def validate_completeness!
103
+ return if completeness.nil?
104
+
105
+ unless Glossarist::GlossaryDefinition::COMPLETENESS_VALUES
106
+ .include?(completeness)
107
+ raise ArgumentError,
108
+ "PartitiveRelation#completeness has invalid value " \
109
+ "#{completeness.inspect}; must be one of " \
110
+ "#{GlossaryDefinition::COMPLETENESS_VALUES.join(', ')}"
111
+ end
112
+ end
113
+
114
+ def validate_plurality!
115
+ plurality&.validate!
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # TypeSharedPlurality — semantic claims encoded by ISO 704's
6
+ # close-set double line and broken line notation, promoted from
7
+ # diagram-notation flags to structured data so tools can reason
8
+ # about plurality directly.
9
+ #
10
+ # A PartitiveRelation has at most one TypeSharedPlurality block.
11
+ # Absent means: no type-shared plurality claim is being made.
12
+ #
13
+ # Replaces the prior `markers` field (which encoded the same
14
+ # information as opaque strings like "double" and "dashed").
15
+ class TypeSharedPlurality < Lutaml::Model::Serializable
16
+ attribute :is_shared, :boolean
17
+ attribute :is_uncertain, :boolean, default: -> { false }
18
+ attribute :shared_type, ConceptRef
19
+
20
+ key_value do
21
+ map :is_shared, to: :is_shared
22
+ map :is_uncertain, to: :is_uncertain
23
+ map :shared_type, to: :shared_type
24
+ end
25
+
26
+ def validate!
27
+ unless is_shared.is_a?(TrueClass) || is_shared.is_a?(FalseClass)
28
+ raise ArgumentError,
29
+ "TypeSharedPlurality#is_shared is required (boolean)"
30
+ end
31
+
32
+ if is_uncertain == true && is_shared == false
33
+ raise ArgumentError,
34
+ "TypeSharedPlurality#is_uncertain requires is_shared: true " \
35
+ "(ISO 704 broken line qualifies the close-set double line claim)"
36
+ end
37
+
38
+ self
39
+ end
40
+ end
41
+ end
42
+ end
data/lib/glossarist/v3.rb CHANGED
@@ -9,6 +9,9 @@ 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 :PartitiveRelation, "glossarist/v3/partitive_relation"
13
+ autoload :PartitiveMember, "glossarist/v3/partitive_member"
14
+ autoload :TypeSharedPlurality, "glossarist/v3/type_shared_plurality"
12
15
  autoload :ConceptData, "glossarist/v3/concept_data"
13
16
  autoload :LocalizedConcept, "glossarist/v3/localized_concept"
14
17
  autoload :ManagedConceptData, "glossarist/v3/managed_concept_data"
@@ -23,6 +26,9 @@ module Glossarist
23
26
  Configuration.register_model(LocalizedConcept, id: :localized_concept)
24
27
  Configuration.register_model(ConceptRef, id: :concept_ref)
25
28
  Configuration.register_model(RelatedConcept, id: :related_concept)
29
+ Configuration.register_model(PartitiveRelation, id: :partitive_relation)
30
+ Configuration.register_model(PartitiveMember, id: :partitive_member)
31
+ Configuration.register_model(TypeSharedPlurality, id: :type_shared_plurality)
26
32
  Configuration.register_model(ManagedConceptData, id: :managed_concept_data)
27
33
  Configuration.register_model(ManagedConcept, id: :managed_concept)
28
34
  Configuration.register_model(ConceptDocument, id: :concept_document)
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module Validation
5
+ module Rules
6
+ # Validates semantic invariants of PartitiveRelation entries
7
+ # that the model constructor does NOT enforce. Specifically:
8
+ #
9
+ # - error when a relation has fewer than 2 partitives
10
+ # (ISO 704: "two or more"; single binary should use
11
+ # has_part edge instead)
12
+ # - error when two relations share the same comprehensive
13
+ # AND the same non-nil criterion (duplicate decomposition;
14
+ # ISO 12620 coordinate-concept coherence)
15
+ # - warning when a relation has no criterion (cannot
16
+ # distinguish from siblings sharing the comprehensive)
17
+ # - error when plurality.is_uncertain is set without
18
+ # plurality.is_shared: true (broken-line qualifies
19
+ # close-set double line)
20
+ # - error when ExternalConcept (status: external) lacks
21
+ # at least one designation
22
+ #
23
+ # The model constructor already rejects empty comprehensive,
24
+ # empty partitives list, self-loops, invalid enum values.
25
+ class PartitiveRelationRule < Base
26
+ def code = "GLS-221"
27
+ def category = :schema
28
+ def severity = "error"
29
+ def scope = :concept
30
+
31
+ def applicable?(context)
32
+ concept = context.concept
33
+ return false unless concept.is_a?(V3::ManagedConcept)
34
+
35
+ concept.partitive_relations&.any? || external?(concept)
36
+ end
37
+
38
+ def check(context)
39
+ concept = context.concept
40
+ fname = context.file_name
41
+ issues = []
42
+
43
+ return issues unless concept.is_a?(V3::ManagedConcept)
44
+
45
+ relations = Array(concept.partitive_relations)
46
+
47
+ relations.each_with_index do |rel, idx|
48
+ check_cardinality(rel, idx, fname, issues)
49
+ check_criterion_present(rel, idx, fname, issues)
50
+ check_plurality_coherence(rel, idx, fname, issues)
51
+ end
52
+
53
+ check_duplicate_decomposition(relations, fname, issues)
54
+ check_external_concept(concept, fname, issues)
55
+
56
+ issues
57
+ end
58
+
59
+ private
60
+
61
+ def external?(concept)
62
+ concept.status == "external"
63
+ end
64
+
65
+ def check_cardinality(rel, idx, fname, issues)
66
+ return if rel.partitives.length >= 2
67
+
68
+ issues << issue(
69
+ "partitive_relation #{idx + 1} has fewer than 2 partitives " \
70
+ "(ISO 704 requires two or more); a single binary has_part edge " \
71
+ "should be used instead",
72
+ location: fname,
73
+ )
74
+ end
75
+
76
+ def check_criterion_present(rel, idx, fname, issues)
77
+ return if rel.criterion && !rel.criterion.empty?
78
+
79
+ issues << issue(
80
+ "partitive_relation #{idx + 1} has no criterion; cannot verify " \
81
+ "distinctness from sibling relations sharing the comprehensive " \
82
+ "(ISO 12620 coordinate-concept coherence)",
83
+ severity: "warning",
84
+ location: fname,
85
+ suggestion: "Add a criterion: { eng: '...' } field",
86
+ )
87
+ end
88
+
89
+ def check_plurality_coherence(rel, idx, fname, issues)
90
+ plural = rel.plurality
91
+ return unless plural
92
+ return unless plural.is_uncertain
93
+ return if plural.is_shared
94
+
95
+ issues << issue(
96
+ "partitive_relation #{idx + 1}.plurality: is_uncertain requires " \
97
+ "is_shared: true (ISO 704 broken line qualifies the close-set " \
98
+ "double line claim)",
99
+ location: fname,
100
+ )
101
+ end
102
+
103
+ def check_duplicate_decomposition(relations, fname, issues)
104
+ grouped = {}
105
+ relations.each_with_index do |rel, idx|
106
+ next unless rel.criterion && !rel.criterion.empty?
107
+
108
+ key = criterion_key(rel)
109
+ (grouped[key] ||= []) << idx
110
+ end
111
+
112
+ grouped.each do |key, idxs|
113
+ next if idxs.length == 1
114
+
115
+ issues << issue(
116
+ "duplicate PartitiveRelation for comprehensive " \
117
+ "#{key.first.inspect} with criterion #{key.last.inspect} " \
118
+ "(relations ##{idxs.map { |i| i + 1 }.join(', ')}); " \
119
+ "two relations sharing comprehensive AND criterion are the " \
120
+ "same decomposition",
121
+ location: fname,
122
+ )
123
+ end
124
+ end
125
+
126
+ def criterion_key(rel)
127
+ comp = rel.comprehensive
128
+ comp_id = comp.is_a?(ConceptRef) ? [comp.source, comp.id] : nil
129
+ [comp_id, rel.criterion]
130
+ end
131
+
132
+ def check_external_concept(concept, fname, issues)
133
+ return unless external?(concept)
134
+
135
+ has_designation = concept.localizations.any? do |_, lc|
136
+ lc.is_a?(LocalizedConcept) && lc.terms&.any?
137
+ end
138
+
139
+ return if has_designation
140
+
141
+ issues << issue(
142
+ "ExternalConcept (status: external) must have at least one " \
143
+ "designation — even external concepts have a name",
144
+ location: fname,
145
+ )
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -4,5 +4,5 @@
4
4
  #
5
5
 
6
6
  module Glossarist
7
- VERSION = "2.12.0"
7
+ VERSION = "2.13.0"
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.12.0
4
+ version: 2.13.0
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-15 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
@@ -310,9 +310,12 @@ files:
310
310
  - lib/glossarist/rdf/gloss_locality.rb
311
311
  - lib/glossarist/rdf/gloss_localized_concept.rb
312
312
  - lib/glossarist/rdf/gloss_non_verbal_rep.rb
313
+ - lib/glossarist/rdf/gloss_partitive_member.rb
314
+ - lib/glossarist/rdf/gloss_partitive_relation.rb
313
315
  - lib/glossarist/rdf/gloss_pronunciation.rb
314
316
  - lib/glossarist/rdf/gloss_reference.rb
315
317
  - lib/glossarist/rdf/gloss_table.rb
318
+ - lib/glossarist/rdf/gloss_type_shared_plurality.rb
316
319
  - lib/glossarist/rdf/localized_literal.rb
317
320
  - lib/glossarist/rdf/lutaml_ext.rb
318
321
  - lib/glossarist/rdf/namespaces.rb
@@ -394,7 +397,10 @@ files:
394
397
  - lib/glossarist/v3/localized_concept.rb
395
398
  - lib/glossarist/v3/managed_concept.rb
396
399
  - lib/glossarist/v3/managed_concept_data.rb
400
+ - lib/glossarist/v3/partitive_member.rb
401
+ - lib/glossarist/v3/partitive_relation.rb
397
402
  - lib/glossarist/v3/related_concept.rb
403
+ - lib/glossarist/v3/type_shared_plurality.rb
398
404
  - lib/glossarist/validation.rb
399
405
  - lib/glossarist/validation/asset_index.rb
400
406
  - lib/glossarist/validation/bibliography_index.rb
@@ -436,6 +442,7 @@ files:
436
442
  - lib/glossarist/validation/rules/orphaned_bibliography_rule.rb
437
443
  - lib/glossarist/validation/rules/orphaned_images_rule.rb
438
444
  - lib/glossarist/validation/rules/orphaned_l10n_files_rule.rb
445
+ - lib/glossarist/validation/rules/partitive_relation_rule.rb
439
446
  - lib/glossarist/validation/rules/preferred_term_rule.rb
440
447
  - lib/glossarist/validation/rules/ref_shape_rule.rb
441
448
  - lib/glossarist/validation/rules/registry.rb