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
@@ -0,0 +1,102 @@
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 hyperedge (PartitiveMember, GenericMember,
7
+ # future AssociativeMember, SequentialMember).
8
+ #
9
+ # Carries the shared ISO 704:2022 MECE dimensions on every member:
10
+ # presence — required (default) | optional
11
+ # count — exactly_one (default) | at_least_one | multiple
12
+ #
13
+ # Type-specific extensions live on the leaves:
14
+ # PartitiveMember — `is_delimiting` (Boolean): per ISO 704
15
+ # §5.5.4.2.2, a part is or is not a delimiting part. Binary role.
16
+ # GenericMember — `characteristic` (LocalizedString): per ISO 704
17
+ # §5.5.4.2.1, each species carries a delimiting characteristic
18
+ # text (e.g., "detecting movement by means of light sensors")
19
+ # that distinguishes it from coordinate concepts under the
20
+ # hyperedge's criterion of subdivision.
21
+ #
22
+ # The combination (optional, at_least_one) is invalid and collapses
23
+ # to (optional, multiple). Multiplicity is the SSOT for the
24
+ # validation; this class delegates to it.
25
+ class HyperedgeMember < Lutaml::Model::Serializable
26
+ DEFAULT_PRESENCE = "required"
27
+ DEFAULT_COUNT = "exactly_one"
28
+
29
+ attribute :ref, ConceptRef
30
+ attribute :presence, :string,
31
+ values: Glossarist::GlossaryDefinition::MEMBER_PRESENCE_VALUES,
32
+ default: -> { DEFAULT_PRESENCE }
33
+ attribute :count, :string,
34
+ values: Glossarist::GlossaryDefinition::MEMBER_COUNT_VALUES,
35
+ default: -> { DEFAULT_COUNT }
36
+
37
+ key_value do
38
+ map :ref, to: :ref
39
+ map :presence, to: :presence
40
+ map :count, to: :count
41
+ end
42
+
43
+ def initialize(*)
44
+ if instance_of?(HyperedgeMember)
45
+ raise NotImplementedError,
46
+ "HyperedgeMember is abstract; instantiate " \
47
+ "PartitiveMember or GenericMember instead"
48
+ end
49
+
50
+ super
51
+ end
52
+
53
+ def validate!
54
+ validate_ref!
55
+ validate_presence_count!
56
+ self
57
+ end
58
+
59
+ def required?
60
+ presence == "required"
61
+ end
62
+
63
+ def optional?
64
+ presence == "optional"
65
+ end
66
+
67
+ private
68
+
69
+ def validate_ref!
70
+ return if ref.is_a?(ConceptRef) && (ref.source || ref.id || ref.text)
71
+
72
+ raise ArgumentError,
73
+ "#{self.class.name}#ref must be a non-empty ConceptRef " \
74
+ "(source, id, or text required)"
75
+ end
76
+
77
+ # Delegates the MECE combination check to Multiplicity (the SSOT).
78
+ # Multiplicity.multiplicity_from_pair raises ArgumentError on the
79
+ # invalid (optional + at_least_one) combo with the canonical message.
80
+ # The returned name is discarded — only the validation side matters.
81
+ def validate_presence_count!
82
+ unless Glossarist::GlossaryDefinition::MEMBER_PRESENCE_VALUES
83
+ .include?(presence)
84
+ raise ArgumentError,
85
+ "#{self.class.name}#presence has invalid value " \
86
+ "#{presence.inspect}; must be one of " \
87
+ "#{GlossaryDefinition::MEMBER_PRESENCE_VALUES.join(', ')}"
88
+ end
89
+
90
+ unless Glossarist::GlossaryDefinition::MEMBER_COUNT_VALUES
91
+ .include?(count)
92
+ raise ArgumentError,
93
+ "#{self.class.name}#count has invalid value " \
94
+ "#{count.inspect}; must be one of " \
95
+ "#{GlossaryDefinition::MEMBER_COUNT_VALUES.join(', ')}"
96
+ end
97
+
98
+ Multiplicity.multiplicity_from_pair(presence, count)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,72 @@
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
+ # rubocop:disable Style/MutableConstant — these MUST stay
19
+ # mutable; register() adds entries at load time.
20
+ BY_WIRE_KEY = {}
21
+ BY_TYPE_TAG = {}
22
+ BY_RDF_TYPE = {}
23
+ # rubocop:enable Style/MutableConstant
24
+
25
+ Mutex = ::Mutex.new
26
+
27
+ class << self
28
+ # Register a concrete hyperedge class. Idempotent.
29
+ def register(cls)
30
+ return if cls.nil? || cls.const_defined?(:WIRE_KEY) == false
31
+
32
+ Mutex.synchronize do
33
+ wire = cls::WIRE_KEY
34
+ tag = cls::TYPE_TAG
35
+ rdf = cls::RDF_TYPE
36
+
37
+ BY_WIRE_KEY[wire] = cls unless wire.nil? || wire.empty?
38
+ BY_TYPE_TAG[tag] = cls unless tag.nil? || tag.empty?
39
+ BY_RDF_TYPE[rdf] = cls unless rdf.nil? || rdf.empty?
40
+ end
41
+ end
42
+
43
+ # Iterate every concrete leaf (use this in parser / serializer
44
+ # / loader to stay type-blind).
45
+ def all_classes
46
+ BY_TYPE_TAG.values.uniq
47
+ end
48
+
49
+ def for_wire_key(key)
50
+ BY_WIRE_KEY[key]
51
+ end
52
+
53
+ def for_type_tag(tag)
54
+ BY_TYPE_TAG[tag]
55
+ end
56
+
57
+ def for_rdf_type(rdf_type)
58
+ BY_RDF_TYPE[rdf_type]
59
+ end
60
+
61
+ # Reset (spec helper — never call from production code).
62
+ def reset!
63
+ Mutex.synchronize do
64
+ BY_WIRE_KEY.clear
65
+ BY_TYPE_TAG.clear
66
+ BY_RDF_TYPE.clear
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
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
+ unless path
49
+ raise WriteError,
50
+ "cannot derive file path — comprehensive must be non-empty"
51
+ end
52
+
53
+ FileUtils.mkdir_p(File.dirname(path))
54
+ File.write(path, serialize(hyperedge))
55
+ path
56
+ end
57
+
58
+ def write_all(hyperedges)
59
+ Array(hyperedges).map { |h| write(h) }
60
+ end
61
+
62
+ private
63
+
64
+ # Serialize with stable key ordering: $id, type, status,
65
+ # comprehensive, members, completeness, criterion, sources,
66
+ # notes. Matches the concept-model per-file format.
67
+ def serialize(hyperedge)
68
+ hash = hyperedge.to_hash
69
+ hash["$id"] = hyperedge.file_id || hyperedge.derived_file_id
70
+ hash["type"] = hyperedge.class::TYPE_TAG
71
+ # Re-order for stable output.
72
+ ordered = {}
73
+ ordered["$id"] = hash.delete("$id")
74
+ ordered["type"] = hash.delete("type")
75
+ ordered["status"] = hash.delete("status") if hash.key?("status")
76
+ ordered["comprehensive"] = hash.delete("comprehensive")
77
+ ordered["members"] = hash.delete("members")
78
+ ordered["completeness"] = hash.delete("completeness") if hash.key?("completeness")
79
+ ordered["criterion"] = hash.delete("criterion") if hash.key?("criterion")
80
+ ordered["sources"] = hash.delete("sources") if hash.key?("sources")
81
+ ordered["notes"] = hash.delete("notes") if hash.key?("notes")
82
+ ordered.merge!(hash) # any future fields append in declaration order
83
+ ordered.compact!
84
+
85
+ YAML.dump(ordered).gsub(/^---\s*\n/, "---\n")
86
+ end
87
+ end
88
+ end
89
+ 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,51 +2,21 @@
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. The
6
+ # `comprehensive` of its parent PartitiveHyperedge denotes the
7
+ # whole concept; this member denotes one of its parts.
8
8
  #
9
- # ISO 704:2022 partitive member notation uses two orthogonal
10
- # dimensions (MECE decomposition):
9
+ # Per ISO 704:2022 §5.5.4.2.2, a part is either delimiting or not:
10
+ # a delimiting part behaves like a delimiting characteristic — it
11
+ # distinguishes the comprehensive (whole) from coordinate concepts.
12
+ # Example: for "optomechanical mouse", the delimiting parts are
13
+ # mouse ball, x/y-axis rollers, infrared emitter/sensor — they
14
+ # distinguish it from "mechanical mouse" and "optical mouse".
15
+ # Mouse button is NOT delimiting (all computer mice have buttons).
11
16
  #
12
- # presence (line stylesolid 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 }
17
+ # The binary role is sufficient no per-member delimiting text is
18
+ # needed because the part itself IS the delimiting marker.
19
+ class PartitiveMember < HyperedgeMember
50
20
  attribute :is_delimiting, :boolean, default: -> { false }
51
21
 
52
22
  key_value do
@@ -56,58 +26,9 @@ module Glossarist
56
26
  map :is_delimiting, to: :is_delimiting
57
27
  end
58
28
 
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
29
  def delimiting?
74
30
  is_delimiting == true
75
31
  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
111
32
  end
112
33
  end
113
34
  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