glossarist 2.13.6 → 2.13.8

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 +41 -0
  12. data/lib/glossarist/rdf/gloss_generic_relation.rb +44 -0
  13. data/lib/glossarist/rdf/gloss_nary_member.rb +52 -0
  14. data/lib/glossarist/rdf/gloss_nary_relation.rb +56 -0
  15. data/lib/glossarist/rdf/gloss_partitive_member.rb +7 -25
  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 +85 -53
  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 +52 -0
  27. data/lib/glossarist/v3/hyperedge_index.rb +89 -0
  28. data/lib/glossarist/v3/hyperedge_member.rb +102 -0
  29. data/lib/glossarist/v3/hyperedge_registry.rb +72 -0
  30. data/lib/glossarist/v3/hyperedge_writer.rb +89 -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 +13 -92
  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 +18 -4
  42. data/lib/glossarist/v3/partitive_relation.rb +0 -114
@@ -3,7 +3,10 @@
3
3
  require "rdf/turtle"
4
4
  require "shacl"
5
5
  require "pathname"
6
- require "glossarist/validation/shacl_validator"
6
+ # Entry-point require triggers the autoload chain
7
+ # (Glossarist → Validation → ShaclValidator). Internal-path requires
8
+ # are forbidden by the autoload rule.
9
+ require "glossarist"
7
10
 
8
11
  namespace :glossarist do
9
12
  desc "Validate all .ttl outputs against concept-model SHACL shapes. " \
@@ -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,68 @@ 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
+ .grep(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)
189
+
190
+ build_gloss_nary_member(Rdf::GlossGenericMember, member)
191
+ end
172
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
+ # Per-type fields are dispatched by class — PartitiveMember
196
+ # carries `is_delimiting`; GenericMember carries `characteristic`.
197
+ def build_gloss_nary_member(gloss_member_class, member)
173
198
  ref = member.ref
174
199
  ref_attrs = if ref.is_a?(Glossarist::ConceptRef)
175
200
  { ref_id: ref.id, ref_source: ref.source, ref_text: ref.text }
@@ -177,27 +202,34 @@ module Glossarist
177
202
  {}
178
203
  end
179
204
 
180
- Rdf::GlossPartitiveMember.new(
205
+ type_attrs =
206
+ case member
207
+ when V3::PartitiveMember
208
+ { is_delimiting: member.is_delimiting }
209
+ when V3::GenericMember
210
+ { characteristic: member.characteristic }
211
+ else
212
+ {}
213
+ end
214
+
215
+ gloss_member_class.new(
181
216
  **ref_attrs,
182
217
  presence: member.presence,
183
218
  count: member.count,
184
- is_delimiting: member.is_delimiting,
219
+ **type_attrs,
185
220
  )
186
221
  end
187
222
 
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.
223
+ # Single SSOT for ConceptRef → concept URI. Uses
224
+ # Glossarist::ConceptRef.qualified_id for the [source, id] string
225
+ # so external ConceptRefs don't collide with local ones of the
226
+ # same id.
193
227
  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?
228
+ qualified = Glossarist::ConceptRef.qualified_id(ref)
229
+ return nil if qualified.nil?
198
230
 
199
231
  Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
200
- "concept/#{slug.join('/')}"
232
+ "concept/#{qualified.split(':', 2).join('/')}"
201
233
  end
202
234
 
203
235
  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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # GenericMember — one member (species) of a GenericHyperedge. The
6
+ # `comprehensive` of its parent GenericHyperedge denotes the genus
7
+ # concept; this member denotes one species.
8
+ #
9
+ # Per ISO 704:2022 §5.5.4.2.1, the intension of a species includes
10
+ # the intension of the genus plus at least one additional
11
+ # DELIMITING CHARACTERISTIC. Each species in a coordinate set
12
+ # (siblings sharing the same criterion of subdivision) carries its
13
+ # own delimiting characteristic value.
14
+ #
15
+ # Example (ISO 704 §5.5.4.2.1 Example 3):
16
+ # Hyperedge comprehensive: computer mouse
17
+ # Hyperedge criterion (of subdivision): means of movement detection
18
+ # Members:
19
+ # - mechanical mouse
20
+ # characteristic: "detecting movement by means of rollers"
21
+ # - optomechanical mouse
22
+ # characteristic: "detecting movement by means of rollers and light sensors"
23
+ # - optical mouse
24
+ # characteristic: "detecting movement by means of light sensors"
25
+ #
26
+ # Multidimensionality (ISO 704 §5.6.3): the same genus can have
27
+ # multiple GenericHyperedges, each with a different criterion
28
+ # (e.g., means of movement detection vs computer connection). The
29
+ # `characteristic` on each member is meaningful only within its
30
+ # parent hyperedge's criterion context.
31
+ #
32
+ # Distinct from PartitiveMember: partitive members are binary
33
+ # delimiting (a part is or isn't delimiting); generic members are
34
+ # textually delimiting (each species has its own characteristic
35
+ # phrase that distinguishes it from coordinate concepts).
36
+ class GenericMember < HyperedgeMember
37
+ # The delimiting characteristic of this species under the parent
38
+ # hyperedge's criterion of subdivision. Localized — different
39
+ # languages may express the same characteristic differently.
40
+ # Required for semantic completeness per ISO 704 §5.5.4.2.1, but
41
+ # the model permits nil to allow incremental population.
42
+ attribute :characteristic, :hash
43
+
44
+ key_value do
45
+ map :ref, to: :ref
46
+ map :presence, to: :presence
47
+ map :count, to: :count
48
+ map :characteristic, to: :characteristic
49
+ end
50
+ end
51
+ end
52
+ 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