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
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # HyperedgeMember — abstract base shape for members of any
6
+ # n-ary concept-system relation (PartitiveMember, GenericMember,
7
+ # future AssociativeMember, SequentialMember).
8
+ #
9
+ # Carries the shared ISO 704:2022 MECE dimensions: presence × count,
10
+ # plus the orthogonal is_delimiting flag.
11
+ #
12
+ # Concrete leaf classes inherit and may add type-specific fields.
13
+ # See docs/design/abstract-nary-relation.md (concept-model repo).
14
+ class HyperedgeMember < Lutaml::Model::Serializable
15
+ DEFAULT_PRESENCE = "required"
16
+ DEFAULT_COUNT = "exactly_one"
17
+
18
+ attribute :ref, ConceptRef
19
+ attribute :presence, :string,
20
+ values: Glossarist::GlossaryDefinition::MEMBER_PRESENCE_VALUES,
21
+ default: -> { DEFAULT_PRESENCE }
22
+ attribute :count, :string,
23
+ values: Glossarist::GlossaryDefinition::MEMBER_COUNT_VALUES,
24
+ default: -> { DEFAULT_COUNT }
25
+ attribute :is_delimiting, :boolean, default: -> { false }
26
+
27
+ key_value do
28
+ map :ref, to: :ref
29
+ map :presence, to: :presence
30
+ map :count, to: :count
31
+ map :is_delimiting, to: :is_delimiting
32
+ end
33
+
34
+ def initialize(*)
35
+ if instance_of?(HyperedgeMember)
36
+ raise NotImplementedError,
37
+ "HyperedgeMember is abstract; instantiate " \
38
+ "PartitiveMember or GenericMember instead"
39
+ end
40
+
41
+ super
42
+ end
43
+
44
+ def validate!
45
+ validate_ref!
46
+ validate_presence_count!
47
+ self
48
+ end
49
+
50
+ def required?
51
+ presence == "required"
52
+ end
53
+
54
+ def optional?
55
+ presence == "optional"
56
+ end
57
+
58
+ def delimiting?
59
+ is_delimiting == true
60
+ end
61
+
62
+ private
63
+
64
+ def validate_ref!
65
+ return if ref.is_a?(ConceptRef) && (ref.source || ref.id || ref.text)
66
+
67
+ raise ArgumentError,
68
+ "#{self.class.name}#ref must be a non-empty ConceptRef " \
69
+ "(source, id, or text required)"
70
+ end
71
+
72
+ # Delegates the MECE combination check to Multiplicity (the SSOT).
73
+ # Multiplicity.multiplicity_from_pair raises ArgumentError on the
74
+ # invalid (optional + at_least_one) combo with the canonical message.
75
+ # The returned name is discarded — only the validation side matters.
76
+ def validate_presence_count!
77
+ unless Glossarist::GlossaryDefinition::MEMBER_PRESENCE_VALUES
78
+ .include?(presence)
79
+ raise ArgumentError,
80
+ "#{self.class.name}#presence has invalid value " \
81
+ "#{presence.inspect}; must be one of " \
82
+ "#{GlossaryDefinition::MEMBER_PRESENCE_VALUES.join(', ')}"
83
+ end
84
+
85
+ unless Glossarist::GlossaryDefinition::MEMBER_COUNT_VALUES
86
+ .include?(count)
87
+ raise ArgumentError,
88
+ "#{self.class.name}#count has invalid value " \
89
+ "#{count.inspect}; must be one of " \
90
+ "#{GlossaryDefinition::MEMBER_COUNT_VALUES.join(', ')}"
91
+ end
92
+
93
+ Multiplicity.multiplicity_from_pair(presence, count)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # HyperedgeRegistry — single SSOT for hyperedge-class lookup by
6
+ # every external identifier (wire key, type tag, RDF type).
7
+ #
8
+ # Auto-populates on class inheritance via AbstractHyperedge.inherited.
9
+ # Concrete leaves declare WIRE_KEY / TYPE_TAG / RDF_TYPE constants
10
+ # in a per-class metadata block; the registry reads them once at
11
+ # registration time.
12
+ #
13
+ # Adding a new hyperedge type means declaring one leaf class with
14
+ # the metadata block. No edit to any other file (parser, serializer,
15
+ # validator, RDF emitter, RelationLoader) — they all iterate or
16
+ # resolve through this registry.
17
+ module HyperedgeRegistry
18
+ # Mutable: register adds entries at load time. DO NOT freeze.
19
+ BY_WIRE_KEY = {}
20
+ BY_TYPE_TAG = {}
21
+ BY_RDF_TYPE = {}
22
+
23
+ Mutex = ::Mutex.new
24
+
25
+ class << self
26
+ # Register a concrete hyperedge class. Idempotent.
27
+ def register(cls)
28
+ return if cls.nil? || cls.const_defined?(:WIRE_KEY) == false
29
+
30
+ Mutex.synchronize do
31
+ wire = cls::WIRE_KEY
32
+ tag = cls::TYPE_TAG
33
+ rdf = cls::RDF_TYPE
34
+
35
+ BY_WIRE_KEY[wire] = cls unless wire.nil? || wire.empty?
36
+ BY_TYPE_TAG[tag] = cls unless tag.nil? || tag.empty?
37
+ BY_RDF_TYPE[rdf] = cls unless rdf.nil? || rdf.empty?
38
+ end
39
+ end
40
+
41
+ # Iterate every concrete leaf (use this in parser / serializer
42
+ # / loader to stay type-blind).
43
+ def all_classes
44
+ BY_TYPE_TAG.values.uniq
45
+ end
46
+
47
+ def for_wire_key(key)
48
+ BY_WIRE_KEY[key]
49
+ end
50
+
51
+ def for_type_tag(tag)
52
+ BY_TYPE_TAG[tag]
53
+ end
54
+
55
+ def for_rdf_type(rdf_type)
56
+ BY_RDF_TYPE[rdf_type]
57
+ end
58
+
59
+ # Reset (spec helper — never call from production code).
60
+ def reset!
61
+ Mutex.synchronize do
62
+ BY_WIRE_KEY.clear
63
+ BY_TYPE_TAG.clear
64
+ BY_RDF_TYPE.clear
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ module Glossarist
8
+ module V3
9
+ # HyperedgeWriter — the WRITE half of per-file hyperedge storage.
10
+ #
11
+ # Mirror of RelationLoader. Each hyperedge is serialized to
12
+ # `relations/<comprehensive-id>/<criterion-slug>.yaml` with the
13
+ # full per-file wire format ($id, type, comprehensive, members,
14
+ # criterion, sources, notes, status, completeness).
15
+ #
16
+ # Per-file storage means a single concept (e.g., OIML 5.1
17
+ # measurement standard) can have N hyperedges (one per criterion),
18
+ # each in its own file under relations/<comp-id>/.
19
+ class HyperedgeWriter
20
+ class WriteError < ::StandardError
21
+ end
22
+
23
+ class << self
24
+ # Write a single hyperedge to its per-file location under
25
+ # `relations_dir`. Creates the comprehensive-id subdirectory
26
+ # if missing. Returns the absolute path written.
27
+ def write(hyperedge, relations_dir)
28
+ new(relations_dir).write(hyperedge)
29
+ end
30
+
31
+ # Write a batch of hyperedges. Returns an Array<String> of
32
+ # paths written. Hyperedges with the same comprehensive are
33
+ # written to the same subdirectory.
34
+ def write_all(hyperedges, relations_dir)
35
+ new(relations_dir).write_all(hyperedges)
36
+ end
37
+ end
38
+
39
+ def initialize(relations_dir)
40
+ @relations_dir = Pathname.new(relations_dir)
41
+ end
42
+
43
+ def write(hyperedge)
44
+ raise WriteError, "not an AbstractHyperedge: #{hyperedge.class}" unless
45
+ hyperedge.is_a?(AbstractHyperedge)
46
+
47
+ path = hyperedge.file_path(@relations_dir)
48
+ raise WriteError,
49
+ "cannot derive file path — comprehensive must be non-empty" unless path
50
+
51
+ FileUtils.mkdir_p(File.dirname(path))
52
+ File.write(path, serialize(hyperedge))
53
+ path
54
+ end
55
+
56
+ def write_all(hyperedges)
57
+ Array(hyperedges).map { |h| write(h) }
58
+ end
59
+
60
+ private
61
+
62
+ # Serialize with stable key ordering: $id, type, status,
63
+ # comprehensive, members, completeness, criterion, sources,
64
+ # notes. Matches the concept-model per-file format.
65
+ def serialize(hyperedge)
66
+ hash = hyperedge.to_hash
67
+ hash["$id"] = hyperedge.file_id || hyperedge.derived_file_id
68
+ hash["type"] = hyperedge.class::TYPE_TAG
69
+ # Re-order for stable output.
70
+ ordered = {}
71
+ ordered["$id"] = hash.delete("$id")
72
+ ordered["type"] = hash.delete("type")
73
+ ordered["status"] = hash.delete("status") if hash.key?("status")
74
+ ordered["comprehensive"] = hash.delete("comprehensive")
75
+ ordered["members"] = hash.delete("members")
76
+ ordered["completeness"] = hash.delete("completeness") if hash.key?("completeness")
77
+ ordered["criterion"] = hash.delete("criterion") if hash.key?("criterion")
78
+ ordered["sources"] = hash.delete("sources") if hash.key?("sources")
79
+ ordered["notes"] = hash.delete("notes") if hash.key?("notes")
80
+ ordered.merge!(hash) # any future fields append in declaration order
81
+ ordered.compact!
82
+
83
+ YAML.dump(ordered).gsub(/^---\s*\n/, "---\n")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,18 +2,41 @@
2
2
 
3
3
  module Glossarist
4
4
  module V3
5
+ # V3 ManagedConcept.
6
+ #
7
+ # V3 storage model:
8
+ # - Concept metadata lives here on the concept (data, related,
9
+ # dates, sources, status).
10
+ # - Hyperedges (PartitiveHyperedge, GenericHyperedge) are PER-FILE:
11
+ # stored at relations/<comprehensive-id>/<criterion-slug>.yaml
12
+ # and loaded via Glossarist::V3::RelationLoader. They are NOT
13
+ # serialized inline on the concept YAML.
14
+ #
15
+ # This is the v3 clean break — the bundled format
16
+ # (`partitive_relations: [...]` inline on the concept YAML) is
17
+ # removed. Concept-model removed it without backward compat in
18
+ # commit a62cf85; ruby aligns.
19
+ #
20
+ # #relations is the unified accessor. The list is populated by
21
+ # callers (GlossaryStore#relations_for, RelationLoader, direct
22
+ # construction). NO typed projections — callers filter:
23
+ #
24
+ # concept.relations.select { |r| r.is_a?(PartitiveHyperedge) }
25
+ # concept.relations.select { |r| r.comprehensive.id == my_id }
5
26
  class ManagedConcept < Glossarist::ManagedConcept
6
27
  attribute :data, V3::ManagedConceptData, default: -> { V3::ManagedConceptData.new }
7
28
  attribute :related, V3::RelatedConcept, collection: true
8
- attribute :partitive_relations, V3::PartitiveRelation, collection: true
9
29
  attribute :dates, V3::ConceptDate, collection: true
10
30
  attribute :date_accepted, V3::ConceptDate
11
31
  attribute :sources, V3::ConceptSource, collection: true
12
32
 
33
+ # Unified hyperedge accessor. NOT serialized (per-file storage);
34
+ # populated externally by GlossaryStore / RelationLoader.
35
+ attribute :relations, V3::AbstractHyperedge, collection: true
36
+
13
37
  key_value do
14
38
  map :data, to: :data
15
39
  map :related, to: :related
16
- map :partitive_relations, to: :partitive_relations
17
40
  map :dates, to: :dates
18
41
  map %i[date_accepted dateAccepted],
19
42
  with: { from: :date_accepted_from_yaml, to: :date_accepted_to_yaml }
@@ -22,6 +45,7 @@ module Glossarist
22
45
  with: { from: :uuid_from_yaml, to: :uuid_to_yaml }
23
46
  map :schema_version, to: :schema_version
24
47
  map :sources, to: :sources
48
+ # relations: intentionally NOT mapped — per-file storage only.
25
49
  end
26
50
 
27
51
  def date_accepted_from_yaml(model, value)
@@ -29,6 +53,33 @@ module Glossarist
29
53
  { "date" => value, "type" => "accepted" },
30
54
  )
31
55
  end
56
+
57
+ # Strict setter — rejects duplicates by identity (type +
58
+ # comprehensive + criterion fingerprint). Adding the same
59
+ # hyperedge twice is always a bug; dedupe hides bugs.
60
+ def relations=(list)
61
+ seen = {}
62
+ Array(list).each do |rel|
63
+ key = hyperedge_identity(rel)
64
+ if seen[key]
65
+ raise ArgumentError,
66
+ "duplicate hyperedge #{key.inspect} — pass each " \
67
+ "decomposition once"
68
+ end
69
+ seen[key] = rel
70
+ end
71
+ super(seen.values)
72
+ end
73
+
74
+ private
75
+
76
+ def hyperedge_identity(rel)
77
+ return rel.object_id.to_s unless rel.is_a?(V3::AbstractHyperedge)
78
+
79
+ comp = Glossarist::ConceptRef.qualified_id(rel.comprehensive)
80
+ crit = rel.criterion.is_a?(Hash) ? rel.criterion.sort.to_h : rel.criterion
81
+ "#{rel.class}:#{comp}:#{crit}"
82
+ end
32
83
  end
33
84
  end
34
85
  end
@@ -47,4 +47,3 @@ module Glossarist
47
47
  end
48
48
  end
49
49
  end
50
-
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # PartitiveHyperedge — an ISO 704 / ISO 1087-1 / ISO 12620 partitive
6
+ # hyperedge connecting a comprehensive concept (superordinate concept
7
+ # partitive) to two or more partitive concepts (subordinate concepts
8
+ # partitive) which fitted together constitute the comprehensive.
9
+ #
10
+ # Inherits structure and validations from AbstractHyperedge.
11
+ # The `comprehensive` field denotes the whole concept.
12
+ #
13
+ # Per-file storage: lives at
14
+ # relations/<comprehensive-id>/<criterion-slug>.yaml — see
15
+ # docs/design/relations-as-files.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 — the SSOT for this leaf's external
21
+ # identifiers. Adding a new hyperedge type means declaring a new
22
+ # class with an equivalent block; no other file needs editing
23
+ # (parsers, serializers, validators, RDF emitters, and the loader
24
+ # all dispatch through HyperedgeRegistry by these constants).
25
+ class PartitiveHyperedge < AbstractHyperedge
26
+ # YAML key on Concept (legacy bundled-format wire name; kept for
27
+ # backward compat with concept-model).
28
+ WIRE_KEY = "partitive_relations"
29
+
30
+ # Per-file `type:` discriminator in relations/<id>/<slug>.yaml.
31
+ TYPE_TAG = "partitive_relation"
32
+
33
+ # RDF type URI (gloss: ontology — concept-model contract).
34
+ RDF_TYPE = "gloss:PartitiveRelation"
35
+
36
+ # Member class — narrows the parent's members: HyperedgeMember.
37
+ MEMBER_CLASS = PartitiveMember
38
+
39
+ # Legacy v1 wire keys that migrate to this class on parse.
40
+ V1_WIRE_KEYS = %w[partitive_hyperedges].freeze
41
+
42
+ # Short label for diff display ("PART" / "GEN" / etc.).
43
+ KIND_LABEL = "PART"
44
+
45
+ attribute :members, PartitiveMember, collection: true
46
+ end
47
+
48
+ # Auto-register with HyperedgeRegistry. Adding a new hyperedge
49
+ # type means adding a class with an equivalent block + this one
50
+ # register call — nothing else in the codebase changes.
51
+ HyperedgeRegistry.register(PartitiveHyperedge)
52
+ end
53
+ end
@@ -2,112 +2,15 @@
2
2
 
3
3
  module Glossarist
4
4
  module V3
5
- # PartitiveMember — one member of a PartitiveRelation, carrying
6
- # a ConceptRef to the partitive concept plus ISO 704:2022
7
- # multiplicity and delimiting metadata.
5
+ # PartitiveMember — one member of a PartitiveHyperedge. Inherits
6
+ # the ISO 704:2022 MECE shape from HyperedgeMember; the
7
+ # `comprehensive` of its parent PartitiveHyperedge denotes the
8
+ # whole concept.
8
9
  #
9
- # ISO 704:2022 partitive member notation uses two orthogonal
10
- # dimensions (MECE decomposition):
11
- #
12
- # presence (line style solid vs dashed):
13
- # required — solid line (must exist in every instance)
14
- # optional — dashed line (may exist; exists in some instances)
15
- #
16
- # count (line count — how many):
17
- # exactly_one — 1 line
18
- # at_least_one — 1 solid + 1 dashed
19
- # multiple — 2 lines
20
- #
21
- # Valid combinations (5):
22
- # required + exactly_one = compulsory (1 solid)
23
- # optional + exactly_one = optional (1 dashed)
24
- # required + multiple = compulsory_multiple (2 solid)
25
- # optional + multiple = optional_multiple (2 dashed)
26
- # required + at_least_one = compulsory_at_least_one (1 solid + 1 dashed)
27
- #
28
- # Invalid: optional + at_least_one (collapses to optional + multiple
29
- # because "at least one, if present" = "zero or more" = optional_multiple).
30
- #
31
- # is_delimiting (orthogonal, bold 3x-width line in diagram):
32
- # A delimiting part behaves like a delimiting characteristic:
33
- # it distinguishes the comprehensive from coordinate concepts.
34
- # Example (ISO 704 §5.5.4.2.2): for "optomechanical mouse",
35
- # the delimiting parts are mouse ball, x/y-axis rollers,
36
- # infrared emitter/sensor — they distinguish it from
37
- # "mechanical mouse" and "optical mouse". Mouse button is
38
- # NOT delimiting (all computer mice have buttons).
39
- class PartitiveMember < Lutaml::Model::Serializable
40
- DEFAULT_PRESENCE = "required"
41
- DEFAULT_COUNT = "exactly_one"
42
-
43
- attribute :ref, ConceptRef
44
- attribute :presence, :string,
45
- values: Glossarist::GlossaryDefinition::PARTITIVE_PRESENCE_VALUES,
46
- default: -> { DEFAULT_PRESENCE }
47
- attribute :count, :string,
48
- values: Glossarist::GlossaryDefinition::PARTITIVE_COUNT_VALUES,
49
- default: -> { DEFAULT_COUNT }
50
- attribute :is_delimiting, :boolean, default: -> { false }
51
-
52
- key_value do
53
- map :ref, to: :ref
54
- map :presence, to: :presence
55
- map :count, to: :count
56
- map :is_delimiting, to: :is_delimiting
57
- end
58
-
59
- def validate!
60
- validate_ref!
61
- validate_presence_count!
62
- self
63
- end
64
-
65
- def required?
66
- presence == "required"
67
- end
68
-
69
- def optional?
70
- presence == "optional"
71
- end
72
-
73
- def delimiting?
74
- is_delimiting == true
75
- end
76
-
77
- private
78
-
79
- def validate_ref!
80
- return if ref.is_a?(ConceptRef) && (ref.source || ref.id || ref.text)
81
-
82
- raise ArgumentError,
83
- "PartitiveMember#ref must be a non-empty ConceptRef " \
84
- "(source, id, or text required)"
85
- end
86
-
87
- def validate_presence_count!
88
- unless Glossarist::GlossaryDefinition::PARTITIVE_PRESENCE_VALUES
89
- .include?(presence)
90
- raise ArgumentError,
91
- "PartitiveMember#presence has invalid value " \
92
- "#{presence.inspect}; must be one of " \
93
- "#{GlossaryDefinition::PARTITIVE_PRESENCE_VALUES.join(', ')}"
94
- end
95
-
96
- unless Glossarist::GlossaryDefinition::PARTITIVE_COUNT_VALUES
97
- .include?(count)
98
- raise ArgumentError,
99
- "PartitiveMember#count has invalid value " \
100
- "#{count.inspect}; must be one of " \
101
- "#{GlossaryDefinition::PARTITIVE_COUNT_VALUES.join(', ')}"
102
- end
103
-
104
- if presence == "optional" && count == "at_least_one"
105
- raise ArgumentError,
106
- "PartitiveMember presence=optional + count=at_least_one is " \
107
- "invalid — it collapses to optional + multiple (zero or more). " \
108
- "Use presence: optional, count: multiple instead."
109
- end
110
- end
10
+ # Distinct leaf class for type safety and to leave room for
11
+ # partitive-specific extensions if needed.
12
+ class PartitiveMember < HyperedgeMember
13
+ # Inherits all attributes and validations from HyperedgeMember.
111
14
  end
112
15
  end
113
16
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Glossarist
7
+ module V3
8
+ # RelationLoader — scans a directory for per-file hyperedge files
9
+ # and returns typed instances.
10
+ #
11
+ # Files live at `relations/<comprehensive-id>/<criterion-slug>.yaml`.
12
+ # The `type` field on each file discriminates which concrete
13
+ # hyperedge class to instantiate. Dispatch is via HyperedgeRegistry
14
+ # — adding a new hyperedge type requires no edit here.
15
+ #
16
+ # Usage:
17
+ # relations = RelationLoader.load_all("path/to/dataset/relations")
18
+ # partitive = RelationLoader.load_for_concept("path/to/dataset", "5-1")
19
+ class RelationLoader
20
+ class LoadError < ::StandardError
21
+ end
22
+
23
+ class << self
24
+ # Load every relation file under `dir`. Returns a hash keyed
25
+ # by comprehensive id, value = array of typed hyperedges.
26
+ def load_all(dir)
27
+ new(dir).load_all
28
+ end
29
+
30
+ # Load all relation files for a single comprehensive id.
31
+ # `dataset_root` is the path containing the `relations/` directory.
32
+ def load_for_concept(dataset_root, comprehensive_id)
33
+ new(File.join(dataset_root, "relations")).load_for_comprehensive(comprehensive_id)
34
+ end
35
+
36
+ # Load a single relation file. Returns a typed hyperedge
37
+ # instance (PartitiveHyperedge, GenericHyperedge, etc.).
38
+ def load_file(path)
39
+ new(File.dirname(path, 2)).load_path(path)
40
+ end
41
+ end
42
+
43
+ def initialize(relations_dir)
44
+ @relations_dir = Pathname.new(relations_dir)
45
+ end
46
+
47
+ def load_all
48
+ each_relation_path.with_object({}) do |path, h|
49
+ rel = load_path(path)
50
+ comp_id = Glossarist::ConceptRef.qualified_id(rel.comprehensive)
51
+ (h[comp_id] ||= []) << rel
52
+ end
53
+ end
54
+
55
+ def load_for_comprehensive(comprehensive_id)
56
+ dir = @relations_dir.join(comprehensive_id.to_s)
57
+ return [] unless dir.exist?
58
+
59
+ Dir.glob("#{dir}/*.yaml").map { |p| load_path(Pathname.new(p)) }
60
+ end
61
+
62
+ def load_path(path)
63
+ path = Pathname.new(path)
64
+ doc = YAML.load_file(path)
65
+ unless doc.is_a?(Hash) && doc["type"]
66
+ raise LoadError, "#{path} missing required `type` field"
67
+ end
68
+
69
+ klass = HyperedgeRegistry.for_type_tag(doc["type"])
70
+ unless klass
71
+ known = HyperedgeRegistry.all_classes.map { |c| c::TYPE_TAG }.join(", ")
72
+ raise LoadError, "#{path} has unknown type #{doc['type'].inspect}; " \
73
+ "expected one of #{known}"
74
+ end
75
+
76
+ # Preserve the source $id so round-trip writes go to the same
77
+ # file path. Without this, write-back would derive a new path
78
+ # from comprehensive + criterion and could fragment files.
79
+ file_id = doc["$id"]
80
+ instance = klass.from_hash(doc)
81
+ instance.file_id = file_id if file_id && instance.respond_to?(:file_id=)
82
+ instance
83
+ end
84
+
85
+ private
86
+
87
+ def each_relation_path
88
+ return enum_for(:each_relation_path) unless block_given?
89
+
90
+ return unless @relations_dir.exist?
91
+
92
+ Dir.glob("#{@relations_dir}/**/*.yaml").each do |p|
93
+ yield Pathname.new(p)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/glossarist/v3.rb CHANGED
@@ -9,14 +9,17 @@ 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"
12
+ autoload :HyperedgeMember, "glossarist/v3/hyperedge_member"
13
+ autoload :AbstractHyperedge, "glossarist/v3/abstract_hyperedge"
14
+ autoload :HyperedgeRegistry, "glossarist/v3/hyperedge_registry"
15
+ autoload :HyperedgeIndex, "glossarist/v3/hyperedge_index"
16
+ autoload :PartitiveHyperedge, "glossarist/v3/partitive_hyperedge"
13
17
  autoload :PartitiveMember, "glossarist/v3/partitive_member"
14
- # Multiplicity is a derived-view utility module (SSOT for the
15
- # ISO 704:2022 (presence, count) → name mapping), not a Lutaml
16
- # model — so it is autoloaded but NOT registered via
17
- # Configuration.register_model. Used by renderers/viewers to
18
- # display the ISO name; the PartitiveMember model itself only
19
- # carries the orthogonal presence + count dimensions.
18
+ autoload :GenericHyperedge, "glossarist/v3/generic_hyperedge"
19
+ autoload :GenericMember, "glossarist/v3/generic_member"
20
+ autoload :DefinitionType, "glossarist/v3/definition_type"
21
+ autoload :RelationLoader, "glossarist/v3/relation_loader"
22
+ autoload :HyperedgeWriter, "glossarist/v3/hyperedge_writer"
20
23
  autoload :Multiplicity, "glossarist/v3/multiplicity"
21
24
  autoload :ConceptData, "glossarist/v3/concept_data"
22
25
  autoload :LocalizedConcept, "glossarist/v3/localized_concept"
@@ -32,10 +35,24 @@ module Glossarist
32
35
  Configuration.register_model(LocalizedConcept, id: :localized_concept)
33
36
  Configuration.register_model(ConceptRef, id: :concept_ref)
34
37
  Configuration.register_model(RelatedConcept, id: :related_concept)
35
- Configuration.register_model(PartitiveRelation, id: :partitive_relation)
38
+ # HyperedgeMember and AbstractHyperedge are abstract base classes —
39
+ # they are NOT registered as Lutaml models because they must not be
40
+ # instantiable directly. Concrete leaves (PartitiveHyperedge,
41
+ # GenericHyperedge, PartitiveMember, GenericMember) are registered
42
+ # below and also auto-registered with HyperedgeRegistry.
43
+ Configuration.register_model(PartitiveHyperedge, id: :partitive_hyperedge)
36
44
  Configuration.register_model(PartitiveMember, id: :partitive_member)
45
+ Configuration.register_model(GenericHyperedge, id: :generic_hyperedge)
46
+ Configuration.register_model(GenericMember, id: :generic_member)
37
47
  Configuration.register_model(ManagedConceptData, id: :managed_concept_data)
38
48
  Configuration.register_model(ManagedConcept, id: :managed_concept)
39
49
  Configuration.register_model(ConceptDocument, id: :concept_document)
50
+
51
+ # Eager-load concrete hyperedge leaves so HyperedgeRegistry
52
+ # auto-populates via AbstractHyperedge.inherited. Without this,
53
+ # autoload defers leaf definition until first reference, leaving
54
+ # the registry empty at boot.
55
+ PartitiveHyperedge
56
+ GenericHyperedge
40
57
  end
41
58
  end