glossarist 2.13.6 → 2.13.7

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/config.yml +15 -0
  3. data/lib/glossarist/cli/export_command.rb +5 -5
  4. data/lib/glossarist/concept_ref.rb +49 -0
  5. data/lib/glossarist/gcr_package.rb +2 -2
  6. data/lib/glossarist/gcr_validator.rb +19 -1
  7. data/lib/glossarist/glossary_definition.rb +16 -3
  8. data/lib/glossarist/glossary_store.rb +70 -0
  9. data/lib/glossarist/managed_concept.rb +2 -2
  10. data/lib/glossarist/rdf/gloss_concept.rb +3 -0
  11. data/lib/glossarist/rdf/gloss_generic_member.rb +34 -0
  12. data/lib/glossarist/rdf/gloss_generic_relation.rb +44 -0
  13. data/lib/glossarist/rdf/gloss_nary_member.rb +43 -0
  14. data/lib/glossarist/rdf/gloss_nary_relation.rb +56 -0
  15. data/lib/glossarist/rdf/gloss_partitive_member.rb +5 -28
  16. data/lib/glossarist/rdf/gloss_partitive_relation.rb +6 -19
  17. data/lib/glossarist/rdf.rb +4 -0
  18. data/lib/glossarist/tasks/shacl.rake +4 -1
  19. data/lib/glossarist/tasks/sync.rake +4 -1
  20. data/lib/glossarist/tasks.rb +9 -0
  21. data/lib/glossarist/transforms/concept_to_gloss_transform.rb +72 -52
  22. data/lib/glossarist/v3/abstract_hyperedge.rb +187 -0
  23. data/lib/glossarist/v3/definition_type.rb +30 -0
  24. data/lib/glossarist/v3/detailed_definition.rb +4 -0
  25. data/lib/glossarist/v3/generic_hyperedge.rb +34 -0
  26. data/lib/glossarist/v3/generic_member.rb +17 -0
  27. data/lib/glossarist/v3/hyperedge_index.rb +89 -0
  28. data/lib/glossarist/v3/hyperedge_member.rb +97 -0
  29. data/lib/glossarist/v3/hyperedge_registry.rb +70 -0
  30. data/lib/glossarist/v3/hyperedge_writer.rb +87 -0
  31. data/lib/glossarist/v3/managed_concept.rb +53 -2
  32. data/lib/glossarist/v3/managed_concept_data.rb +0 -1
  33. data/lib/glossarist/v3/partitive_hyperedge.rb +53 -0
  34. data/lib/glossarist/v3/partitive_member.rb +8 -105
  35. data/lib/glossarist/v3/relation_loader.rb +98 -0
  36. data/lib/glossarist/v3.rb +25 -8
  37. data/lib/glossarist/validation/rules/concept_context.rb +10 -2
  38. data/lib/glossarist/validation/rules/{partitive_relation_rule.rb → hyperedge_coherence_rule.rb} +22 -16
  39. data/lib/glossarist/version.rb +1 -1
  40. data/lib/glossarist.rb +1 -0
  41. metadata +17 -3
  42. data/lib/glossarist/v3/partitive_relation.rb +0 -114
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../tasks/sync_model"
3
+ # Entry-point require triggers the autoload chain
4
+ # (Glossarist → Tasks → SyncModel) defined in lib/glossarist.rb.
5
+ # Internal-path requires are forbidden by the autoload rule.
6
+ require "glossarist"
4
7
 
5
8
  namespace :glossarist do
6
9
  namespace :sync do
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ # Rake-task namespace for vendored-data sync + SHACL validation.
5
+ # Loaded lazily via autoload from lib/glossarist.rb.
6
+ module Tasks
7
+ autoload :SyncModel, "glossarist/tasks/sync_model"
8
+ end
9
+ end
@@ -33,18 +33,20 @@ module Glossarist
33
33
  Designation::Symbol => :build_gloss_symbol,
34
34
  }.freeze
35
35
 
36
- def self.transform(managed_concept, options = {})
37
- new(managed_concept, options).build
36
+ def self.transform(managed_concept, relations: [], **options)
37
+ new(managed_concept, relations: relations, **options).build
38
38
  end
39
39
 
40
- def self.transform_document(concepts, figures: [], tables: [],
40
+ def self.transform_document(concepts, relations: [], figures: [], tables: [],
41
41
  formulas: [], **options)
42
- new(nil, options).build_document(concepts, figures: figures,
43
- tables: tables, formulas: formulas)
42
+ new(nil, relations: relations, **options).build_document(
43
+ concepts, figures: figures, tables: tables, formulas: formulas
44
+ )
44
45
  end
45
46
 
46
- def initialize(managed_concept, options = {})
47
+ def initialize(managed_concept, relations: [], **options)
47
48
  @concept = managed_concept
49
+ @relations = Array(relations)
48
50
  @options = options
49
51
  end
50
52
 
@@ -63,7 +65,7 @@ module Glossarist
63
65
  Rdf::GlossDocument.to_turtle(doc)
64
66
  end
65
67
 
66
- def to_turtle(concepts_or_concept = nil, figures: [], tables: [],
68
+ def to_turtle(concepts_or_concept = nil, relations: nil, figures: [], tables: [],
67
69
  formulas: [])
68
70
  if concepts_or_concept.is_a?(Array)
69
71
  build_document(concepts_or_concept, figures: figures,
@@ -72,12 +74,12 @@ module Glossarist
72
74
  target = concepts_or_concept || @concept
73
75
  return "" unless target
74
76
 
75
- gc = build_gloss_concept(target)
77
+ gc = build_gloss_concept(target, relations: relations)
76
78
  Rdf::GlossConcept.to_turtle(gc)
77
79
  end
78
80
  end
79
81
 
80
- def to_jsonld(concepts_or_concept = nil, figures: [], tables: [],
82
+ def to_jsonld(concepts_or_concept = nil, relations: nil, figures: [], tables: [],
81
83
  formulas: [])
82
84
  if concepts_or_concept.is_a?(Array)
83
85
  gloss_concepts = concepts_or_concept.map do |c|
@@ -94,7 +96,7 @@ module Glossarist
94
96
  target = concepts_or_concept || @concept
95
97
  return "" unless target
96
98
 
97
- gc = build_gloss_concept(target)
99
+ gc = build_gloss_concept(target, relations: relations)
98
100
  Rdf::GlossConcept.to_jsonld(gc)
99
101
  end
100
102
  end
@@ -108,9 +110,10 @@ module Glossarist
108
110
 
109
111
  private
110
112
 
111
- attr_reader :concept, :options
113
+ attr_reader :concept, :relations, :options
112
114
 
113
- def build_gloss_concept(managed_concept)
115
+ def build_gloss_concept(managed_concept, relations: nil)
116
+ relation_list = relations || @relations
114
117
  identifier = managed_concept.data&.id || managed_concept.identifier
115
118
 
116
119
  localizations = managed_concept.localizations.each_value.map do |l10n|
@@ -130,46 +133,66 @@ module Glossarist
130
133
  domains: build_gloss_domains(managed_concept.data&.domains,
131
134
  identifier),
132
135
  dates: build_gloss_dates(managed_concept.dates, identifier),
133
- partitive_relations: build_gloss_partitive_relations(
134
- v3_partitive_relations(managed_concept), identifier
136
+ partitive_relations: build_gloss_hyperedges(
137
+ relation_list, V3::PartitiveHyperedge,
138
+ :build_gloss_partitive_member, :partitive_member_ids,
139
+ :partitive_members, Rdf::GlossPartitiveRelation, identifier,
140
+ ),
141
+ generic_relations: build_gloss_hyperedges(
142
+ relation_list, V3::GenericHyperedge,
143
+ :build_gloss_generic_member, :generic_member_ids,
144
+ :generic_members, Rdf::GlossGenericRelation, identifier,
135
145
  ),
136
146
  **rel_targets,
137
147
  )
138
148
  end
139
149
 
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
150
+ # Type-blind hyperedge builder. Dispatches via HyperedgeRegistry;
151
+ # the only per-type data (member class, RDF view attribute names,
152
+ # RDF view class) is passed in. Adding a new hyperedge type means
153
+ # adding one entry to the calling sites above; no edit to this
154
+ # method.
155
+ def build_gloss_hyperedges(relations, hyperedge_class, member_builder,
156
+ member_ids_attr, members_attr,
157
+ gloss_relation_class, identifier)
158
+ Array(relations)
159
+ .select { |rel| rel.is_a?(hyperedge_class) }
160
+ .map { |rel| build_one_gloss_hyperedge(rel, member_builder, member_ids_attr, members_attr, gloss_relation_class, identifier) }
161
+ end
162
+
163
+ def build_one_gloss_hyperedge(rel, member_builder, member_ids_attr,
164
+ members_attr, gloss_relation_class, identifier)
165
+ source_members = Array(rel.members)
166
+ gloss_members = source_members.map { |m| send(member_builder, m) }
167
+ member_uris = source_members
168
+ .select { |m| m.ref.is_a?(Glossarist::ConceptRef) }
169
+ .map { |m| concept_ref_uri(m.ref) }
170
+
171
+ gloss_relation_class.new(
172
+ identifier: identifier.to_s,
173
+ comprehensive_uri: concept_ref_uri(rel.comprehensive),
174
+ member_ids_attr => member_uris,
175
+ members_attr => gloss_members,
176
+ completeness: rel.completeness,
177
+ criterion: rel.criterion,
178
+ )
147
179
  end
148
180
 
149
- def build_gloss_partitive_relations(relations, identifier)
150
- return [] unless relations
151
-
152
- Array(relations).map do |rel|
153
- source_members = Array(rel.partitives)
154
- gloss_members = source_members.map { |m| build_gloss_partitive_member(m) }
155
- member_uris = source_members
156
- .select { |m| m.is_a?(V3::PartitiveMember) && m.ref.is_a?(Glossarist::ConceptRef) }
157
- .map { |m| concept_ref_uri(m.ref) }
181
+ def build_gloss_partitive_member(member)
182
+ return Rdf::GlossPartitiveMember.new unless member.is_a?(V3::PartitiveMember)
158
183
 
159
- Rdf::GlossPartitiveRelation.new(
160
- identifier: identifier.to_s,
161
- comprehensive_uri: concept_ref_uri(rel.comprehensive),
162
- partitive_member_ids: member_uris,
163
- partitive_members: gloss_members,
164
- completeness: rel.completeness,
165
- criterion: rel.criterion,
166
- )
167
- end
184
+ build_gloss_nary_member(Rdf::GlossPartitiveMember, member)
168
185
  end
169
186
 
170
- def build_gloss_partitive_member(member)
171
- return Rdf::GlossPartitiveMember.new unless member.is_a?(V3::PartitiveMember)
187
+ def build_gloss_generic_member(member)
188
+ return Rdf::GlossGenericMember.new unless member.is_a?(V3::GenericMember)
172
189
 
190
+ build_gloss_nary_member(Rdf::GlossGenericMember, member)
191
+ end
192
+
193
+ # Shared member builder. Per-class metadata (the rdf view class
194
+ # is dispatched by the caller) keeps the duplication to one place.
195
+ def build_gloss_nary_member(gloss_member_class, member)
173
196
  ref = member.ref
174
197
  ref_attrs = if ref.is_a?(Glossarist::ConceptRef)
175
198
  { ref_id: ref.id, ref_source: ref.source, ref_text: ref.text }
@@ -177,7 +200,7 @@ module Glossarist
177
200
  {}
178
201
  end
179
202
 
180
- Rdf::GlossPartitiveMember.new(
203
+ gloss_member_class.new(
181
204
  **ref_attrs,
182
205
  presence: member.presence,
183
206
  count: member.count,
@@ -185,19 +208,16 @@ module Glossarist
185
208
  )
186
209
  end
187
210
 
188
- # Single SSOT for ConceptRef → concept URI. Handles all four
189
- # ref shapes (source+id, id-only, source+text, text-only) and
190
- # mirrors GlossCitation.slug's `[source, id].compact.join("/")`
191
- # convention so external ConceptRefs don't collide with local
192
- # ones of the same id.
211
+ # Single SSOT for ConceptRef → concept URI. Uses
212
+ # Glossarist::ConceptRef.qualified_id for the [source, id] string
213
+ # so external ConceptRefs don't collide with local ones of the
214
+ # same id.
193
215
  def concept_ref_uri(ref)
194
- return nil unless ref.is_a?(Glossarist::ConceptRef)
195
-
196
- slug = [ref.source, ref.id || ref.text].compact.reject(&:empty?)
197
- return nil if slug.empty?
216
+ qualified = Glossarist::ConceptRef.qualified_id(ref)
217
+ return nil if qualified.nil?
198
218
 
199
219
  Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
200
- "concept/#{slug.join('/')}"
220
+ "concept/#{qualified.split(':', 2).join('/')}"
201
221
  end
202
222
 
203
223
  def build_gloss_localized_concept(l10n, concept_id)
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Glossarist
6
+ module V3
7
+ # AbstractHyperedge — abstract base shape for all n-ary
8
+ # concept-system relations (PartitiveHyperedge, GenericHyperedge,
9
+ # future AssociativeRelation, SequentialRelation).
10
+ #
11
+ # Carries the shared fields: comprehensive, members[2..*],
12
+ # completeness, criterion, sources, notes, status.
13
+ #
14
+ # Concrete leaves override `members` to specify the typed member
15
+ # class. Shared validations live here.
16
+ class AbstractHyperedge < Lutaml::Model::Serializable
17
+ DEFAULT_COMPLETENESS = "complete"
18
+
19
+ attribute :comprehensive, ConceptRef
20
+ attribute :members, HyperedgeMember, collection: true
21
+ attribute :completeness, :string,
22
+ values: Glossarist::GlossaryDefinition::COMPLETENESS_VALUES,
23
+ default: -> { DEFAULT_COMPLETENESS }
24
+ attribute :criterion, :hash
25
+ attribute :sources, ConceptSource, collection: true
26
+ attribute :notes, :hash
27
+ attribute :status, :string,
28
+ values: Glossarist::GlossaryDefinition::CONCEPT_STATUSES
29
+
30
+ # Per-file identity — set by RelationLoader on parse, used by
31
+ # HyperedgeWriter on serialize. The wire field is `$id` (JSON
32
+ # Schema convention); the value is `<comp-id>/<criterion-slug>`.
33
+ # Not in key_value because lutaml::Model key_value does not
34
+ # natively express `$`-prefixed keys; handled via custom
35
+ # round-trip in HyperedgeWriter / RelationLoader.
36
+ attr_accessor :file_id
37
+
38
+ key_value do
39
+ map :comprehensive, to: :comprehensive
40
+ map :members, to: :members
41
+ map :completeness, to: :completeness
42
+ map :criterion, to: :criterion
43
+ map :sources, to: :sources
44
+ map :notes, to: :notes
45
+ map :status, to: :status
46
+ end
47
+
48
+ def initialize(*)
49
+ if instance_of?(AbstractHyperedge)
50
+ raise NotImplementedError,
51
+ "AbstractHyperedge is abstract; instantiate " \
52
+ "PartitiveHyperedge or GenericHyperedge instead"
53
+ end
54
+
55
+ super
56
+ end
57
+
58
+ def validate!
59
+ validate_comprehensive!
60
+ validate_members!
61
+ validate_self_loop!
62
+ validate_completeness!
63
+ self
64
+ end
65
+
66
+ def complete?
67
+ completeness == "complete"
68
+ end
69
+
70
+ def partial?
71
+ completeness == "partial"
72
+ end
73
+
74
+ # ISO 704: a rake connects to two or more members. A single
75
+ # binary edge is not an n-ary relation.
76
+ def coordinate?
77
+ members.length >= 2
78
+ end
79
+
80
+ # Per-file identity derived from comprehensive + criterion.
81
+ # Format: `<comprehensive-id-dir>/<criterion-slug>` where the
82
+ # dir is `<source>-<id>` (downcased, kebab-case) and the slug
83
+ # is the English criterion (kebab-case) or a structural
84
+ # fallback. Stable across runs for the same content.
85
+ #
86
+ # Returns nil if comprehensive is empty.
87
+ def derived_file_id
88
+ return nil unless comprehensive.is_a?(ConceptRef) && comprehensive.id
89
+
90
+ comp_dir = comprehensive_dir_name
91
+ return nil unless comp_dir
92
+
93
+ slug = criterion_slug
94
+ "#{comp_dir}/#{slug}"
95
+ end
96
+
97
+ # File path under `relations/` directory. Uses #derived_file_id
98
+ # unless #file_id was explicitly set (e.g., by RelationLoader
99
+ # preserving the source path on parse).
100
+ def file_path(relations_dir)
101
+ id = file_id || derived_file_id
102
+ return nil unless id
103
+
104
+ File.join(relations_dir, "#{id}.yaml")
105
+ end
106
+
107
+ # Comprehensive concept as a directory-safe name:
108
+ # `<source>-<id>` lowercased, kebab-case, special chars
109
+ # (including dots) replaced with dashes. Matches the concept-model
110
+ # fixtures (vim-112-02-09, oiml-5-1, example-116-01-01).
111
+ def comprehensive_dir_name
112
+ return nil unless comprehensive.is_a?(ConceptRef)
113
+
114
+ parts = [comprehensive.source, comprehensive.id].compact.reject(&:empty?)
115
+ return nil if parts.empty?
116
+
117
+ parts.join("-").downcase.gsub(/[^a-z0-9\-]/, "-")
118
+ .gsub(/-{2,}/, "-").gsub(/\A-|-\z/, "")
119
+ end
120
+
121
+ # Kebab-case slug derived from the English criterion. Falls back
122
+ # to "decomposition-N" when no English criterion exists (N is
123
+ # derived from the criterion hash for stability).
124
+ def criterion_slug
125
+ eng = criterion.is_a?(Hash) ? (criterion["eng"] || criterion[:eng]) : nil
126
+ return "decomposition-#{criterion_hash}" if eng.nil? || eng.to_s.empty?
127
+
128
+ eng.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
129
+ end
130
+
131
+ private
132
+
133
+ def criterion_hash
134
+ return "0" unless criterion.is_a?(Hash) && !criterion.empty?
135
+
136
+ Digest::MD5.hexdigest(criterion.sort.to_h.inspect)[0..5]
137
+ end
138
+
139
+ def validate_comprehensive!
140
+ return if comprehensive.is_a?(ConceptRef) &&
141
+ (comprehensive.source || comprehensive.id || comprehensive.text)
142
+
143
+ raise ArgumentError,
144
+ "#{self.class.name}#comprehensive must be a non-empty " \
145
+ "ConceptRef (source, id, or text required)"
146
+ end
147
+
148
+ def validate_members!
149
+ if members.empty?
150
+ raise ArgumentError, "#{self.class.name} requires at least one member"
151
+ end
152
+ unless coordinate?
153
+ raise ArgumentError,
154
+ "#{self.class.name} requires >=2 members (ISO 704); a single " \
155
+ "binary edge should be used instead"
156
+ end
157
+
158
+ members.each(&:validate!)
159
+ end
160
+
161
+ def validate_self_loop!
162
+ return unless comprehensive.is_a?(ConceptRef)
163
+
164
+ comp_key = [comprehensive.source, comprehensive.id]
165
+ members.each do |member|
166
+ next unless member.ref.is_a?(ConceptRef)
167
+ next unless comp_key == [member.ref.source, member.ref.id]
168
+
169
+ raise ArgumentError,
170
+ "#{self.class.name}#members cannot include the comprehensive"
171
+ end
172
+ end
173
+
174
+ def validate_completeness!
175
+ return if completeness.nil?
176
+
177
+ unless Glossarist::GlossaryDefinition::COMPLETENESS_VALUES
178
+ .include?(completeness)
179
+ raise ArgumentError,
180
+ "#{self.class.name}#completeness has invalid value " \
181
+ "#{completeness.inspect}; must be one of " \
182
+ "#{GlossaryDefinition::COMPLETENESS_VALUES.join(', ')}"
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # DefinitionType — ISO 704:2022 §5.3 definition strategy constants.
6
+ #
7
+ # SSOT for the set of definition-type values. `DetailedDefinition#type`
8
+ # reads `VALUES` from here, not from `GlossaryDefinition`. The config
9
+ # (config.yml) feeds `GlossaryDefinition::DEFINITION_TYPE_VALUES`, which
10
+ # is consumed here so the source-of-truth chain is:
11
+ #
12
+ # config.yml → GlossaryDefinition::DEFINITION_TYPE_VALUES
13
+ # → DefinitionType::VALUES (consumer-facing)
14
+ # → DetailedDefinition#type (model attribute)
15
+ module DefinitionType
16
+ INTENSIONAL = "intensional"
17
+ EXTENSIONAL = "extensional"
18
+ PARTITIVE = "partitive"
19
+ TRANSLATED = "translated"
20
+
21
+ DEFAULT = INTENSIONAL
22
+
23
+ VALUES = Glossarist::GlossaryDefinition::DEFINITION_TYPE_VALUES.freeze
24
+
25
+ def self.valid?(value)
26
+ VALUES.include?(value)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -6,9 +6,13 @@ module Glossarist
6
6
  attribute :sources, V3::ConceptSource, collection: true
7
7
  attribute :examples, V3::DetailedDefinition, collection: true,
8
8
  initialize_empty: true
9
+ attribute :type, :string,
10
+ values: DefinitionType::VALUES,
11
+ default: -> { DefinitionType::DEFAULT }
9
12
 
10
13
  key_value do
11
14
  map :content, to: :content
15
+ map :type, to: :type
12
16
  map :sources, to: :sources
13
17
  map :examples, to: :examples
14
18
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # GenericHyperedge — an ISO 704 / ISO 1087-1 / ISO 12620 generic
6
+ # hyperedge connecting a comprehensive concept (the genus) to two
7
+ # or more specific concepts (the species) which together
8
+ # constitute a decomposition by some criterion of subdivision.
9
+ #
10
+ # Mirror of PartitiveHyperedge. The `comprehensive` field denotes
11
+ # the genus concept. Multiple GenericHyperedges on the same
12
+ # comprehensive distinguished by `criterion` is the OIML pattern
13
+ # (5.1 measurement standard has 6 criterion groups).
14
+ #
15
+ # See docs/design/generic-relation.md (concept-model repo).
16
+ #
17
+ # The `key_value` mapping is inherited from AbstractHyperedge
18
+ # (single SSOT). Only the typed member collection is narrowed here.
19
+ #
20
+ # Per-class metadata block — see PartitiveHyperedge for field docs.
21
+ class GenericHyperedge < AbstractHyperedge
22
+ WIRE_KEY = "generic_relations"
23
+ TYPE_TAG = "generic_relation"
24
+ RDF_TYPE = "gloss:GenericRelation"
25
+ MEMBER_CLASS = GenericMember
26
+ V1_WIRE_KEYS = [].freeze
27
+ KIND_LABEL = "GEN"
28
+
29
+ attribute :members, GenericMember, collection: true
30
+ end
31
+
32
+ HyperedgeRegistry.register(GenericHyperedge)
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # GenericMember — one member of a GenericHyperedge. Carries the same
6
+ # MECE dimensions as PartitiveMember; the comprehensive of its
7
+ # parent GenericHyperedge denotes the genus concept.
8
+ #
9
+ # See docs/design/generic-relation.md and docs/design/abstract-nary-relation.md
10
+ # (concept-model repo).
11
+ class GenericMember < HyperedgeMember
12
+ # Inherits all attributes and validations from HyperedgeMember.
13
+ # Declared as a distinct class for type safety and to leave room
14
+ # for future generic-specific extensions.
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # HyperedgeIndex — derived reverse-lookup index over a flat list
6
+ # of hyperedges.
7
+ #
8
+ # A hyperedge is directional: 1 comprehensive → N members. To
9
+ # answer the reverse query ("which hyperedges is X a member of?"),
10
+ # build a HyperedgeIndex on demand from the flat list and consult
11
+ # #for_member. The index is NOT stored on the concept — it's a
12
+ # derived view.
13
+ #
14
+ # Use cases:
15
+ # index.for_comprehensive("OIML:5.1")
16
+ # # => 6 hyperedges (different criteria — the OIML pattern)
17
+ # index.for_member("OIML:5.13")
18
+ # # => hyperedges where 5.13 appears as a member
19
+ class HyperedgeIndex
20
+ attr_reader :by_comprehensive, :by_member
21
+
22
+ def initialize(hyperedges)
23
+ @by_comprehensive = {}
24
+ @by_member = {}
25
+
26
+ Array(hyperedges).each do |h|
27
+ register_comprehensive(h)
28
+ register_members(h)
29
+ end
30
+
31
+ @by_comprehensive.freeze
32
+ @by_member.freeze
33
+ end
34
+
35
+ # All hyperedges where the given concept is the comprehensive.
36
+ # Accepts either a qualified-id string ("VIM:1.2") or a ConceptRef.
37
+ def for_comprehensive(qualified_id_or_ref)
38
+ key = resolve_key(qualified_id_or_ref)
39
+ @by_comprehensive[key] || []
40
+ end
41
+
42
+ # All hyperedges where the given concept is one of the members.
43
+ # Accepts either a qualified-id string or a ConceptRef.
44
+ def for_member(qualified_id_or_ref)
45
+ key = resolve_key(qualified_id_or_ref)
46
+ @by_member[key] || []
47
+ end
48
+
49
+ # Every concept that appears anywhere in this hyperedge set
50
+ # (as comprehensive OR as a member), with its roles.
51
+ def all_concept_ids
52
+ (@by_comprehensive.keys | @by_member.keys).freeze
53
+ end
54
+
55
+ private
56
+
57
+ def register_comprehensive(hyperedge)
58
+ return unless hyperedge.is_a?(AbstractHyperedge)
59
+
60
+ key = Glossarist::ConceptRef.qualified_id(hyperedge.comprehensive)
61
+ return unless key
62
+
63
+ (@by_comprehensive[key] ||= []) << hyperedge
64
+ end
65
+
66
+ def register_members(hyperedge)
67
+ return unless hyperedge.is_a?(AbstractHyperedge)
68
+
69
+ Array(hyperedge.members).each do |m|
70
+ next unless m.is_a?(HyperedgeMember)
71
+ next unless m.ref.is_a?(Glossarist::ConceptRef)
72
+
73
+ key = Glossarist::ConceptRef.qualified_id(m.ref)
74
+ next unless key
75
+
76
+ (@by_member[key] ||= []) << hyperedge
77
+ end
78
+ end
79
+
80
+ def resolve_key(qualified_id_or_ref)
81
+ if qualified_id_or_ref.is_a?(Glossarist::ConceptRef)
82
+ Glossarist::ConceptRef.qualified_id(qualified_id_or_ref)
83
+ else
84
+ qualified_id_or_ref.to_s
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end