glossarist 2.13.7 → 2.13.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4d7af45a5f0e045d0f3a18cdcca5791a85bfcb6a16a642af0c1e840b68ef6e8
4
- data.tar.gz: 8fd502d531f3c862c340b3aa19bb1fc4e4b205e7217ba4c79ee6bc1809db0d35
3
+ metadata.gz: 5e42372897ecf000813b37db02afdc6461039b9ee4618182515793d548bd9951
4
+ data.tar.gz: 33f5ba8dfb1b35782fc3711aa9ec7a0f1f675c55a0876c830975d4ca14770761
5
5
  SHA512:
6
- metadata.gz: aedc22ebce41ec42d6bc93e96abef82078b7834b66f08a0e219b08f4b2e0eded91561bcc82af7498ab50aed4e8da32d0cabc23c37b7eb56390ec7028e8b731ba
7
- data.tar.gz: fe063d88bf1297f1c31e72ce545a9a9e32f7deb89f6d24cd6580572f830f605288e57feed04ed3af4ebb9c777320def6c5fc87e9ec82a93ef12c0a070a7f42b7
6
+ metadata.gz: 02d246dd723fd2f2834c54ac2e3d8f4a38251e543d7b677ed0ada8cd11af05cddfd41999f6772bbf8235ba486af8d76a603585185f4fd8475bcfaac22ce72a59
7
+ data.tar.gz: d1f34a759e7505afee75884ca7d5cbaa0b02f547ce8f1464317e00fc31fb011b4cd9820faa4a13d9eaa3e87c5daae74d93236cf940759d8b0cd7367996c00f78
@@ -8,7 +8,14 @@ module Glossarist
8
8
  # deterministic_id from GlossNaryMember. The `rdf do` block
9
9
  # re-declares the predicates because lutaml-model's `rdf` DSL
10
10
  # replaces the parent mapping (not extends).
11
+ #
12
+ # Carries `gloss:characteristic` — the delimiting characteristic
13
+ # text per ISO 704:2022 §5.5.4.2.1 (e.g., "detecting movement by
14
+ # means of light sensors"). Each species in a coordinate set has
15
+ # its own characteristic under the parent hyperedge's criterion.
11
16
  class GlossGenericMember < GlossNaryMember
17
+ attribute :characteristic, :hash
18
+
12
19
  rdf do
13
20
  namespace Namespaces::GlossaristNamespace
14
21
 
@@ -26,8 +33,8 @@ module Glossarist
26
33
  to: :presence
27
34
  predicate :count, namespace: Namespaces::GlossaristNamespace,
28
35
  to: :count
29
- predicate :isDelimiting, namespace: Namespaces::GlossaristNamespace,
30
- to: :is_delimiting
36
+ predicate :characteristic, namespace: Namespaces::GlossaristNamespace,
37
+ to: :characteristic
31
38
  end
32
39
  end
33
40
  end
@@ -4,27 +4,27 @@ require "lutaml/model"
4
4
 
5
5
  module Glossarist
6
6
  module Rdf
7
- # Shared base for typed n-ary relation member RDF views
7
+ # Shared base for typed n-ary hyperedge member RDF views
8
8
  # (GlossPartitiveMember, GlossGenericMember, future leaves).
9
9
  #
10
10
  # Carries the shared attributes and the deterministic_id algorithm.
11
- # Concrete leaves declare their own `rdf do` block because
12
- # lutaml-model's `rdf` DSL replaces (not extends) the parent's
13
- # mapping predicate declarations stay in each leaf.
11
+ # Concrete leaves declare their own `rdf do` block AND their own
12
+ # type-specific attributes — `is_delimiting` on GlossPartitiveMember
13
+ # (binary role, ISO 704 §5.5.4.2.2); `characteristic` on
14
+ # GlossGenericMember (delimiting text, ISO 704 §5.5.4.2.1).
14
15
  #
15
16
  # Subject identity is content-derived so the same member in the
16
- # same relation produces the same URI across runs (and across
17
+ # same hyperedge produces the same URI across runs (and across
17
18
  # processes). Two members with identical ref + dimensions share a
18
19
  # subject — that is intentional: it exposes model-level
19
- # inconsistency if the same partitive concept is given different
20
- # dimensions inside the same dataset.
20
+ # inconsistency if the same concept is given different dimensions
21
+ # inside the same dataset.
21
22
  class GlossNaryMember < Lutaml::Model::Serializable
22
23
  attribute :ref_id, :string
23
24
  attribute :ref_source, :string
24
25
  attribute :ref_text, :string
25
26
  attribute :presence, :string
26
27
  attribute :count, :string
27
- attribute :is_delimiting, :boolean
28
28
 
29
29
  # Human-readable SOURCE:ID when both are present; SHA-256 / 16
30
30
  # hex chars fallback otherwise. Same content → same slug across
@@ -33,10 +33,19 @@ module Glossarist
33
33
  readable = [member.ref_source, member.ref_id].compact.reject(&:empty?)
34
34
  return readable.join(":") unless readable.empty?
35
35
 
36
- DeterministicSlug.from_parts(
37
- member.ref_source, member.ref_id, member.ref_text,
38
- member.presence, member.count, member.is_delimiting
39
- )
36
+ parts = [member.ref_source, member.ref_id, member.ref_text,
37
+ member.presence, member.count]
38
+ # Type-specific discriminator: include the delimiting flag for
39
+ # partitive members (binary role) or the characteristic text
40
+ # for generic members (so members of different criteria don't
41
+ # collide on the same hash input).
42
+ case member
43
+ when GlossPartitiveMember
44
+ parts << member.is_delimiting
45
+ when GlossGenericMember
46
+ parts << member.characteristic
47
+ end
48
+ DeterministicSlug.from_parts(*parts)
40
49
  end
41
50
  end
42
51
  end
@@ -8,7 +8,12 @@ module Glossarist
8
8
  # deterministic_id from GlossNaryMember. The `rdf do` block
9
9
  # re-declares the predicates because lutaml-model's `rdf` DSL
10
10
  # replaces the parent mapping (not extends).
11
+ #
12
+ # Carries `gloss:isDelimiting` — the binary role flag per
13
+ # ISO 704:2022 §5.5.4.2.2 (a part is or is not a delimiting part).
11
14
  class GlossPartitiveMember < GlossNaryMember
15
+ attribute :is_delimiting, :boolean
16
+
12
17
  rdf do
13
18
  namespace Namespaces::GlossaristNamespace
14
19
 
@@ -136,12 +136,12 @@ module Glossarist
136
136
  partitive_relations: build_gloss_hyperedges(
137
137
  relation_list, V3::PartitiveHyperedge,
138
138
  :build_gloss_partitive_member, :partitive_member_ids,
139
- :partitive_members, Rdf::GlossPartitiveRelation, identifier,
139
+ :partitive_members, Rdf::GlossPartitiveRelation, identifier
140
140
  ),
141
141
  generic_relations: build_gloss_hyperedges(
142
142
  relation_list, V3::GenericHyperedge,
143
143
  :build_gloss_generic_member, :generic_member_ids,
144
- :generic_members, Rdf::GlossGenericRelation, identifier,
144
+ :generic_members, Rdf::GlossGenericRelation, identifier
145
145
  ),
146
146
  **rel_targets,
147
147
  )
@@ -156,7 +156,7 @@ module Glossarist
156
156
  member_ids_attr, members_attr,
157
157
  gloss_relation_class, identifier)
158
158
  Array(relations)
159
- .select { |rel| rel.is_a?(hyperedge_class) }
159
+ .grep(hyperedge_class)
160
160
  .map { |rel| build_one_gloss_hyperedge(rel, member_builder, member_ids_attr, members_attr, gloss_relation_class, identifier) }
161
161
  end
162
162
 
@@ -192,6 +192,8 @@ module Glossarist
192
192
 
193
193
  # Shared member builder. Per-class metadata (the rdf view class
194
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`.
195
197
  def build_gloss_nary_member(gloss_member_class, member)
196
198
  ref = member.ref
197
199
  ref_attrs = if ref.is_a?(Glossarist::ConceptRef)
@@ -200,11 +202,21 @@ module Glossarist
200
202
  {}
201
203
  end
202
204
 
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
+
203
215
  gloss_member_class.new(
204
216
  **ref_attrs,
205
217
  presence: member.presence,
206
218
  count: member.count,
207
- is_delimiting: member.is_delimiting,
219
+ **type_attrs,
208
220
  )
209
221
  end
210
222
 
@@ -77,6 +77,54 @@ module Glossarist
77
77
  members.length >= 2
78
78
  end
79
79
 
80
+ # ── External-concept awareness ─────────────────────────────────
81
+ #
82
+ # ISO 704:2022 diagrams use parenthetical notation for external
83
+ # concepts — concepts referenced from this dataset but defined
84
+ # elsewhere (status: external). Detection requires concept
85
+ # resolution (looking up the referenced concept's status), so
86
+ # these methods take a resolver block: `->(ref) { store.concept(ref) }`.
87
+ #
88
+ # The model stays pure — it does NOT carry a store reference.
89
+ # Consumers inject the resolver at query time.
90
+
91
+ # True if the comprehensive resolves (via the supplied resolver)
92
+ # to a concept with status: external.
93
+ def external_comprehensive?(resolver = nil)
94
+ return false unless comprehensive.is_a?(ConceptRef)
95
+ return false unless resolver
96
+
97
+ concept = resolver.call(comprehensive)
98
+ concept_is_external?(concept)
99
+ end
100
+
101
+ # Array of members whose refs resolve to status: external.
102
+ # Empty when no resolver is supplied (detection is opt-in).
103
+ def external_members(resolver = nil)
104
+ return [] unless resolver
105
+
106
+ members.select do |m|
107
+ next false unless m.is_a?(HyperedgeMember)
108
+ next false unless m.ref.is_a?(ConceptRef)
109
+
110
+ concept = resolver.call(m.ref)
111
+ concept_is_external?(concept)
112
+ end
113
+ end
114
+
115
+ # True if any external comprehensive or member lacks a
116
+ # `provided_by` edge — i.e., the decomposition dangles. The
117
+ # resolver is used twice: once to detect externals, once to
118
+ # check each external's related edges.
119
+ def dangling_externals?(resolver = nil)
120
+ return false unless resolver
121
+
122
+ candidates = []
123
+ candidates << comprehensive if external_comprehensive?(resolver)
124
+ candidates.concat(external_members(resolver).map(&:ref))
125
+ candidates.any? { |ref| !has_provided_by?(ref, resolver) }
126
+ end
127
+
80
128
  # Per-file identity derived from comprehensive + criterion.
81
129
  # Format: `<comprehensive-id-dir>/<criterion-slug>` where the
82
130
  # dir is `<source>-<id>` (downcased, kebab-case) and the slug
@@ -114,8 +162,8 @@ module Glossarist
114
162
  parts = [comprehensive.source, comprehensive.id].compact.reject(&:empty?)
115
163
  return nil if parts.empty?
116
164
 
117
- parts.join("-").downcase.gsub(/[^a-z0-9\-]/, "-")
118
- .gsub(/-{2,}/, "-").gsub(/\A-|-\z/, "")
165
+ parts.join("-").downcase.gsub(/[^a-z0-9-]/, "-")
166
+ .gsub(/-{2,}/, "-").gsub(/\A-|-\z/, "")
119
167
  end
120
168
 
121
169
  # Kebab-case slug derived from the English criterion. Falls back
@@ -130,6 +178,20 @@ module Glossarist
130
178
 
131
179
  private
132
180
 
181
+ def concept_is_external?(concept)
182
+ concept.respond_to?(:status) && concept.status == "external"
183
+ end
184
+
185
+ # True if the concept referenced by `ref` has at least one
186
+ # `provided_by` edge in its related list. The resolver must
187
+ # return a concept with a `related` collection.
188
+ def has_provided_by?(ref, resolver)
189
+ concept = resolver.call(ref)
190
+ return false unless concept.respond_to?(:related)
191
+
192
+ Array(concept.related).any? { |r| r.respond_to?(:type) && r.type == "provided_by" }
193
+ end
194
+
133
195
  def criterion_hash
134
196
  return "0" unless criterion.is_a?(Hash) && !criterion.empty?
135
197
 
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # ConceptSystem — first-class concept system entity per ISO 12620 A.7.
6
+ #
7
+ # Promotes the previously-implicit concept system (domains + tags +
8
+ # relation graph) to an explicit first-class entity. Per-file at
9
+ # `concept-systems/<id>.yaml`.
10
+ #
11
+ # A ConceptSystem carries:
12
+ # - id: stable identifier
13
+ # - name: localized human-readable name
14
+ # - type: ConceptSystemType (generic / partitive / sequential /
15
+ # associative / mixed)
16
+ # - members: ConceptRefs of concepts in the system
17
+ # - hyperedges: references to per-file hyperedge $ids
18
+ # - root_concepts: top-level concepts (no superordinate)
19
+ # - sources, status: provenance and lifecycle
20
+ #
21
+ # See docs/design/concept-systems.md in concept-model repo.
22
+ class ConceptSystem < Lutaml::Model::Serializable
23
+ attribute :id, :string
24
+ attribute :name, :hash
25
+ attribute :type, :string,
26
+ values: Glossarist::V3::ConceptSystemType::VALUES
27
+ attribute :domain, ConceptRef
28
+ attribute :purpose, :hash
29
+ attribute :members, ConceptRef, collection: true
30
+ attribute :hyperedges, :string, collection: true
31
+ attribute :root_concepts, ConceptRef, collection: true
32
+ attribute :sources, ConceptSource, collection: true
33
+ attribute :status, :string
34
+
35
+ key_value do
36
+ map :id, to: :id
37
+ map :name, to: :name
38
+ map :type, to: :type
39
+ map :domain, to: :domain
40
+ map :purpose, to: :purpose
41
+ map :members, to: :members
42
+ map :hyperedges, to: :hyperedges
43
+ map :root_concepts, to: :root_concepts, with: { from: :root_concepts_from, to: :root_concepts_to }
44
+ map :sources, to: :sources
45
+ map :status, to: :status
46
+ end
47
+
48
+ def validate!
49
+ validate_id!
50
+ validate_name!
51
+ validate_type!
52
+ validate_members!
53
+ self
54
+ end
55
+
56
+ def generic? = type == ConceptSystemType::GENERIC
57
+ def partitive? = type == ConceptSystemType::PARTITIVE
58
+ def sequential? = type == ConceptSystemType::SEQUENTIAL
59
+ def associative? = type == ConceptSystemType::ASSOCIATIVE
60
+ def mixed? = type == ConceptSystemType::MIXED
61
+
62
+ private
63
+
64
+ def validate_id!
65
+ return if id && !id.to_s.empty?
66
+
67
+ raise ArgumentError, "ConceptSystem#id must be non-empty"
68
+ end
69
+
70
+ def validate_name!
71
+ return if name.is_a?(Hash) && !name.empty?
72
+
73
+ raise ArgumentError, "ConceptSystem#name must be a non-empty hash (localized)"
74
+ end
75
+
76
+ def validate_type!
77
+ return if ConceptSystemType::VALUES.include?(type)
78
+
79
+ raise ArgumentError,
80
+ "ConceptSystem#type #{type.inspect} invalid; " \
81
+ "must be one of #{ConceptSystemType::VALUES.join(', ')}"
82
+ end
83
+
84
+ def validate_members!
85
+ return if members.is_a?(Array) && members.length.positive?
86
+
87
+ raise ArgumentError, "ConceptSystem#members must have at least one entry"
88
+ end
89
+
90
+ def root_concepts_from(model, value)
91
+ model.root_concepts = Array(value).map do |entry|
92
+ entry.is_a?(ConceptRef) ? entry : ConceptRef.new(entry || {})
93
+ end
94
+ end
95
+
96
+ def root_concepts_to(model, _doc)
97
+ model.root_concepts&.map(&:to_hash)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # ConceptSystemType — ISO 12620 A.7.1 concept system type enum.
6
+ module ConceptSystemType
7
+ GENERIC = "generic"
8
+ PARTITIVE = "partitive"
9
+ SEQUENTIAL = "sequential"
10
+ ASSOCIATIVE = "associative"
11
+ MIXED = "mixed"
12
+
13
+ VALUES = [GENERIC, PARTITIVE, SEQUENTIAL, ASSOCIATIVE, MIXED].freeze
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # ConceptType — ISO 704:2022 §5.2-5.3 general vs individual concept.
6
+ module ConceptType
7
+ GENERAL = "general"
8
+ INDIVIDUAL = "individual"
9
+
10
+ DEFAULT = GENERAL
11
+
12
+ VALUES = [GENERAL, INDIVIDUAL].freeze
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # EquivalenceDegree — ISO 704:2022 §7.7.3 cross-language equivalence.
6
+ module EquivalenceDegree
7
+ FULL = "full"
8
+ PARTIAL = "partial"
9
+ NONE = "none"
10
+ DIRECTIONAL = "directional"
11
+
12
+ DEFAULT = FULL
13
+
14
+ VALUES = [FULL, PARTIAL, NONE, DIRECTIONAL].freeze
15
+ end
16
+ end
17
+ end
@@ -2,16 +2,51 @@
2
2
 
3
3
  module Glossarist
4
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.
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
8
  #
9
- # See docs/design/generic-relation.md and docs/design/abstract-nary-relation.md
10
- # (concept-model repo).
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).
11
36
  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.
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
15
50
  end
16
51
  end
17
52
  end
@@ -3,14 +3,25 @@
3
3
  module Glossarist
4
4
  module V3
5
5
  # HyperedgeMember — abstract base shape for members of any
6
- # n-ary concept-system relation (PartitiveMember, GenericMember,
6
+ # n-ary concept-system hyperedge (PartitiveMember, GenericMember,
7
7
  # future AssociativeMember, SequentialMember).
8
8
  #
9
- # Carries the shared ISO 704:2022 MECE dimensions: presence × count,
10
- # plus the orthogonal is_delimiting flag.
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
11
12
  #
12
- # Concrete leaf classes inherit and may add type-specific fields.
13
- # See docs/design/abstract-nary-relation.md (concept-model repo).
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.
14
25
  class HyperedgeMember < Lutaml::Model::Serializable
15
26
  DEFAULT_PRESENCE = "required"
16
27
  DEFAULT_COUNT = "exactly_one"
@@ -22,13 +33,11 @@ module Glossarist
22
33
  attribute :count, :string,
23
34
  values: Glossarist::GlossaryDefinition::MEMBER_COUNT_VALUES,
24
35
  default: -> { DEFAULT_COUNT }
25
- attribute :is_delimiting, :boolean, default: -> { false }
26
36
 
27
37
  key_value do
28
38
  map :ref, to: :ref
29
39
  map :presence, to: :presence
30
40
  map :count, to: :count
31
- map :is_delimiting, to: :is_delimiting
32
41
  end
33
42
 
34
43
  def initialize(*)
@@ -55,10 +64,6 @@ module Glossarist
55
64
  presence == "optional"
56
65
  end
57
66
 
58
- def delimiting?
59
- is_delimiting == true
60
- end
61
-
62
67
  private
63
68
 
64
69
  def validate_ref!
@@ -15,10 +15,12 @@ module Glossarist
15
15
  # validator, RDF emitter, RelationLoader) — they all iterate or
16
16
  # resolve through this registry.
17
17
  module HyperedgeRegistry
18
- # Mutable: register adds entries at load time. DO NOT freeze.
18
+ # rubocop:disable Style/MutableConstant these MUST stay
19
+ # mutable; register() adds entries at load time.
19
20
  BY_WIRE_KEY = {}
20
21
  BY_TYPE_TAG = {}
21
22
  BY_RDF_TYPE = {}
23
+ # rubocop:enable Style/MutableConstant
22
24
 
23
25
  Mutex = ::Mutex.new
24
26
 
@@ -45,8 +45,10 @@ module Glossarist
45
45
  hyperedge.is_a?(AbstractHyperedge)
46
46
 
47
47
  path = hyperedge.file_path(@relations_dir)
48
- raise WriteError,
49
- "cannot derive file path — comprehensive must be non-empty" unless path
48
+ unless path
49
+ raise WriteError,
50
+ "cannot derive file path — comprehensive must be non-empty"
51
+ end
50
52
 
51
53
  FileUtils.mkdir_p(File.dirname(path))
52
54
  File.write(path, serialize(hyperedge))
@@ -72,7 +74,7 @@ module Glossarist
72
74
  ordered["type"] = hash.delete("type")
73
75
  ordered["status"] = hash.delete("status") if hash.key?("status")
74
76
  ordered["comprehensive"] = hash.delete("comprehensive")
75
- ordered["members"] = hash.delete("members")
77
+ ordered["members"] = hash.delete("members")
76
78
  ordered["completeness"] = hash.delete("completeness") if hash.key?("completeness")
77
79
  ordered["criterion"] = hash.delete("criterion") if hash.key?("criterion")
78
80
  ordered["sources"] = hash.delete("sources") if hash.key?("sources")
@@ -2,15 +2,33 @@
2
2
 
3
3
  module Glossarist
4
4
  module V3
5
- # PartitiveMember — one member of a PartitiveHyperedge. Inherits
6
- # the ISO 704:2022 MECE shape from HyperedgeMember; the
5
+ # PartitiveMember — one member of a PartitiveHyperedge. The
7
6
  # `comprehensive` of its parent PartitiveHyperedge denotes the
8
- # whole concept.
7
+ # whole concept; this member denotes one of its parts.
9
8
  #
10
- # Distinct leaf class for type safety and to leave room for
11
- # partitive-specific extensions if needed.
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).
16
+ #
17
+ # The binary role is sufficient — no per-member delimiting text is
18
+ # needed because the part itself IS the delimiting marker.
12
19
  class PartitiveMember < HyperedgeMember
13
- # Inherits all attributes and validations from HyperedgeMember.
20
+ attribute :is_delimiting, :boolean, default: -> { false }
21
+
22
+ key_value do
23
+ map :ref, to: :ref
24
+ map :presence, to: :presence
25
+ map :count, to: :count
26
+ map :is_delimiting, to: :is_delimiting
27
+ end
28
+
29
+ def delimiting?
30
+ is_delimiting == true
31
+ end
14
32
  end
15
33
  end
16
34
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # SequentialHyperedge — an ISO 12620 A.6.3 / ISO 704:2022 §5.5.5
6
+ # sequential relation connecting a comprehensive concept to two or
7
+ # more subordinate concepts in an ORDERED sequence (temporal,
8
+ # spatial, causal, developmental).
9
+ #
10
+ # Mirror of PartitiveHyperedge and GenericHyperedge. Members are
11
+ # ORDERED — array order is significant. Reversing the array
12
+ # reverses the sequence.
13
+ class SequentialHyperedge < AbstractHyperedge
14
+ WIRE_KEY = "sequential_relations"
15
+ TYPE_TAG = "sequential_relation"
16
+ RDF_TYPE = "gloss:SequentialRelation"
17
+ V1_WIRE_KEYS = [].freeze
18
+
19
+ attribute :members, SequentialMember, collection: true
20
+
21
+ key_value do
22
+ map :comprehensive, to: :comprehensive
23
+ map :members, to: :members
24
+ map :completeness, to: :completeness
25
+ map :criterion, to: :criterion
26
+ map :sources, to: :sources
27
+ map :notes, to: :notes
28
+ map :status, to: :status
29
+ end
30
+
31
+ HyperedgeRegistry.register(SequentialHyperedge)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # SequentialMember — one member of a SequentialHyperedge. Inherits
6
+ # the MECE shape from HyperedgeMember.
7
+ #
8
+ # Distinct leaf class for type safety. The member's POSITION IN THE
9
+ # ARRAY is significant for sequential hyperedges (unlike partitive
10
+ # and generic where order is insignificant).
11
+ class SequentialMember < HyperedgeMember
12
+ # Inherits all attributes and validations from HyperedgeMember.
13
+ end
14
+ end
15
+ end
data/lib/glossarist/v3.rb CHANGED
@@ -17,7 +17,13 @@ module Glossarist
17
17
  autoload :PartitiveMember, "glossarist/v3/partitive_member"
18
18
  autoload :GenericHyperedge, "glossarist/v3/generic_hyperedge"
19
19
  autoload :GenericMember, "glossarist/v3/generic_member"
20
+ autoload :SequentialHyperedge, "glossarist/v3/sequential_hyperedge"
21
+ autoload :SequentialMember, "glossarist/v3/sequential_member"
20
22
  autoload :DefinitionType, "glossarist/v3/definition_type"
23
+ autoload :ConceptType, "glossarist/v3/concept_type"
24
+ autoload :ConceptSystemType, "glossarist/v3/concept_system_type"
25
+ autoload :EquivalenceDegree, "glossarist/v3/equivalence_degree"
26
+ autoload :ConceptSystem, "glossarist/v3/concept_system"
21
27
  autoload :RelationLoader, "glossarist/v3/relation_loader"
22
28
  autoload :HyperedgeWriter, "glossarist/v3/hyperedge_writer"
23
29
  autoload :Multiplicity, "glossarist/v3/multiplicity"
@@ -44,9 +50,12 @@ module Glossarist
44
50
  Configuration.register_model(PartitiveMember, id: :partitive_member)
45
51
  Configuration.register_model(GenericHyperedge, id: :generic_hyperedge)
46
52
  Configuration.register_model(GenericMember, id: :generic_member)
53
+ Configuration.register_model(SequentialHyperedge, id: :sequential_hyperedge)
54
+ Configuration.register_model(SequentialMember, id: :sequential_member)
47
55
  Configuration.register_model(ManagedConceptData, id: :managed_concept_data)
48
56
  Configuration.register_model(ManagedConcept, id: :managed_concept)
49
57
  Configuration.register_model(ConceptDocument, id: :concept_document)
58
+ Configuration.register_model(ConceptSystem, id: :concept_system)
50
59
 
51
60
  # Eager-load concrete hyperedge leaves so HyperedgeRegistry
52
61
  # auto-populates via AbstractHyperedge.inherited. Without this,
@@ -54,5 +63,6 @@ module Glossarist
54
63
  # the registry empty at boot.
55
64
  PartitiveHyperedge
56
65
  GenericHyperedge
66
+ SequentialHyperedge
57
67
  end
58
68
  end
@@ -17,13 +17,21 @@ module Glossarist
17
17
  # — the loader does not run on demand so the validator behaviour
18
18
  # is fully deterministic given the load.
19
19
  class ConceptContext
20
- attr_reader :concept, :file_name, :collection_context, :relations
20
+ attr_reader :concept, :file_name, :collection_context, :relations,
21
+ :concept_resolver
21
22
 
22
- def initialize(concept, file_name:, collection_context:, relations: [])
23
+ # `concept_resolver` is an optional callable that takes a
24
+ # ConceptRef and returns the resolved ManagedConcept (or nil).
25
+ # Rules that need cross-concept lookups (e.g.,
26
+ # ExternalConceptRule's dangling-external detection) use this.
27
+ # When nil, those rules no-op.
28
+ def initialize(concept, file_name:, collection_context:, relations: [],
29
+ concept_resolver: nil)
23
30
  @concept = concept
24
31
  @file_name = file_name
25
32
  @collection_context = collection_context
26
33
  @relations = Array(relations)
34
+ @concept_resolver = concept_resolver
27
35
  end
28
36
 
29
37
  def concept_id
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module Validation
5
+ module Rules
6
+ # Validates that extensional definitions do not contain open-ended
7
+ # wording, per ISO 704:2022 §6.4.5.1. An extensional definition
8
+ # must be an EXHAUSTIVE enumeration — open-ended wordings like
9
+ # `...`, `etc.`, `and so on`, `and similar`, `and others`,
10
+ # `including` violate this principle because they imply the
11
+ # enumeration is incomplete.
12
+ #
13
+ # Mirrors concept-model's `check-definition-rules`
14
+ # §6.4.5.1 open-ended-wording check, exposed as a consumer-side
15
+ # Validation::Rule.
16
+ #
17
+ # Scope: concept (rule runs once per concept). Inspects every
18
+ # DetailedDefinition with `type: extensional` across all of the
19
+ # concept's localized definitions.
20
+ class ExtensionalDefinitionRule < Base
21
+ # ISO 704:2022 §6.4.5.1 — patterns that signal incomplete
22
+ # enumeration in an extensional definition. The list is
23
+ # intentionally conservative: only wordings that clearly
24
+ # indicate "more exist beyond what's listed."
25
+ OPEN_ENDED_PATTERNS = [
26
+ /\.\.\./, # ellipsis
27
+ /\betc\b\.?/i, # etc / etc.
28
+ /\band\s+similar\b/i, # and similar
29
+ /\band\s+so\s+on\b/i, # and so on
30
+ /\band\s+the\s+like\b/i, # and the like
31
+ /\band\s+others?\b/i, # and other / and others
32
+ /\bsuch\s+as\b/i, # such as (often partial)
33
+ /\bincluding\b/i, # including (often partial)
34
+ /\be\.?g\.?\b/i, # e.g. / eg
35
+ ].freeze
36
+
37
+ def code = "GLS-223"
38
+ def category = :schema
39
+ def severity = "warning"
40
+ def scope = :concept
41
+
42
+ def applicable?(context)
43
+ return false unless context.concept.is_a?(V3::ManagedConcept)
44
+
45
+ concept_has_extensional_definition?(context.concept)
46
+ end
47
+
48
+ def check(context)
49
+ issues = []
50
+
51
+ each_extensional_definition(context.concept) do |l10n_lang, defn, path|
52
+ check_definition(defn, l10n_lang, path, context.file_name, issues)
53
+ end
54
+
55
+ issues
56
+ end
57
+
58
+ private
59
+
60
+ def concept_has_extensional_definition?(concept)
61
+ each_extensional_definition(concept).any? { true }
62
+ rescue StopIteration
63
+ false
64
+ end
65
+
66
+ # Yields (lang, definition, path_string) for every extensional
67
+ # definition on every localization of the concept.
68
+ def each_extensional_definition(concept)
69
+ return enum_for(:each_extensional_definition, concept) unless block_given?
70
+
71
+ concept.localizations.each_value do |l10n|
72
+ next unless l10n.respond_to?(:data)
73
+
74
+ lang = l10n.language_code
75
+ %i[definition examples notes].each do |field|
76
+ value = l10n.data&.public_send(field)
77
+ next if value.nil?
78
+
79
+ Array(value).each_with_index do |item, idx|
80
+ next unless item.is_a?(Glossarist::DetailedDefinition) ||
81
+ item.is_a?(V3::DetailedDefinition)
82
+ next unless item.type.to_s == "extensional"
83
+
84
+ yield lang, item, "#{field}[#{idx}]"
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ def check_definition(defn, lang, path, fname, issues)
91
+ content = extract_content(defn)
92
+ return if content.nil? || content.empty?
93
+
94
+ OPEN_ENDED_PATTERNS.each do |pattern|
95
+ match = content.match(pattern)
96
+ next unless match
97
+
98
+ issues << issue(
99
+ "#{path} (#{lang}) extensional definition contains " \
100
+ "open-ended wording #{match[0].inspect}; ISO 704:2022 " \
101
+ "§6.4.5.1 requires exhaustive enumeration in " \
102
+ "extensional definitions",
103
+ location: fname,
104
+ severity: "warning",
105
+ suggestion: "Either enumerate all instances exhaustively, " \
106
+ "or change the definition type to intensional.",
107
+ )
108
+ break # one issue per definition is enough
109
+ end
110
+ end
111
+
112
+ def extract_content(definition)
113
+ return definition.content if definition.respond_to?(:content)
114
+
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module Validation
5
+ module Rules
6
+ # Validates that external concepts referenced by hyperedges have
7
+ # a `provided_by` edge for resolution. Mirrors concept-model's
8
+ # `check-external-as-comprehensive` validator, exposed as a
9
+ # consumer-side Validation::Rule so it runs in the standard
10
+ # validation pipeline.
11
+ #
12
+ # Per concept-model docs/design/external-concepts.md: an external
13
+ # concept (status: external) is referenced from this dataset but
14
+ # defined elsewhere. Without a `provided_by` edge, the reference
15
+ # dangles — there is no resolvable target. ISO 704 models this
16
+ # case as a parenthetical term in the diagram; the data model
17
+ # requires the resolution edge to exist.
18
+ #
19
+ # Scope: concept (rule runs once per concept). The concept's
20
+ # relations are inspected via `context.relations`; each
21
+ # relation's comprehensive + members are checked.
22
+ #
23
+ # Requires a `concept_resolver` on the context. Without one,
24
+ # the rule is a no-op (cannot detect externals without
25
+ # resolution).
26
+ class ExternalConceptRule < Base
27
+ def code = "GLS-222"
28
+ def category = :schema
29
+ def severity = "warning"
30
+ def scope = :concept
31
+
32
+ def applicable?(context)
33
+ return false unless context.concept.is_a?(V3::ManagedConcept)
34
+ return false unless context.concept_resolver
35
+ return false if context.relations.empty?
36
+
37
+ true
38
+ end
39
+
40
+ def check(context)
41
+ resolver = context.concept_resolver
42
+ issues = []
43
+
44
+ context.relations.each_with_index do |rel, idx|
45
+ check_comprehensive(rel, idx, context.file_name, resolver, issues)
46
+ check_members(rel, idx, context.file_name, resolver, issues)
47
+ end
48
+
49
+ issues
50
+ end
51
+
52
+ private
53
+
54
+ def check_comprehensive(rel, idx, fname, resolver, issues)
55
+ return unless rel.external_comprehensive?(resolver)
56
+
57
+ comp_id = Glossarist::ConceptRef.qualified_id(rel.comprehensive)
58
+ issues << issue(
59
+ "relation #{idx + 1} comprehensive #{comp_id.inspect} is " \
60
+ "external (status: external) — confirm a `provided_by` edge " \
61
+ "exists on the external concept so the decomposition resolves",
62
+ location: fname,
63
+ severity: "info",
64
+ suggestion: "Add `provided_by` to #{comp_id}'s related list.",
65
+ )
66
+ end
67
+
68
+ def check_members(rel, idx, fname, resolver, issues)
69
+ rel.external_members(resolver).each_with_index do |member, mi|
70
+ member_id = Glossarist::ConceptRef.qualified_id(member.ref)
71
+ has_resolution = resolver.call(member.ref)&.related&.any? do |r|
72
+ r.respond_to?(:type) && r.type == "provided_by"
73
+ end
74
+
75
+ next if has_resolution
76
+
77
+ issues << issue(
78
+ "relation #{idx + 1}.members[#{mi}] #{member_id.inspect} is " \
79
+ "external (status: external) without a `provided_by` edge — " \
80
+ "the decomposition dangles; no resolvable target",
81
+ location: fname,
82
+ severity: "warning",
83
+ suggestion: "Add `provided_by` to #{member_id}'s related " \
84
+ "list, or define the concept in this dataset.",
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -4,5 +4,5 @@
4
4
  #
5
5
 
6
6
  module Glossarist
7
- VERSION = "2.13.7"
7
+ VERSION = "2.13.9"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glossarist
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.13.7
4
+ version: 2.13.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-29 00:00:00.000000000 Z
11
+ date: 2026-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -398,9 +398,13 @@ files:
398
398
  - lib/glossarist/v3/concept_document.rb
399
399
  - lib/glossarist/v3/concept_ref.rb
400
400
  - lib/glossarist/v3/concept_source.rb
401
+ - lib/glossarist/v3/concept_system.rb
402
+ - lib/glossarist/v3/concept_system_type.rb
403
+ - lib/glossarist/v3/concept_type.rb
401
404
  - lib/glossarist/v3/configuration.rb
402
405
  - lib/glossarist/v3/definition_type.rb
403
406
  - lib/glossarist/v3/detailed_definition.rb
407
+ - lib/glossarist/v3/equivalence_degree.rb
404
408
  - lib/glossarist/v3/generic_hyperedge.rb
405
409
  - lib/glossarist/v3/generic_member.rb
406
410
  - lib/glossarist/v3/hyperedge_index.rb
@@ -415,6 +419,8 @@ files:
415
419
  - lib/glossarist/v3/partitive_member.rb
416
420
  - lib/glossarist/v3/related_concept.rb
417
421
  - lib/glossarist/v3/relation_loader.rb
422
+ - lib/glossarist/v3/sequential_hyperedge.rb
423
+ - lib/glossarist/v3/sequential_member.rb
418
424
  - lib/glossarist/validation.rb
419
425
  - lib/glossarist/validation/asset_index.rb
420
426
  - lib/glossarist/validation/bibliography_index.rb
@@ -442,6 +448,8 @@ files:
442
448
  - lib/glossarist/validation/rules/domain_target_rule.rb
443
449
  - lib/glossarist/validation/rules/duplicate_term_rule.rb
444
450
  - lib/glossarist/validation/rules/entry_status_rule.rb
451
+ - lib/glossarist/validation/rules/extensional_definition_rule.rb
452
+ - lib/glossarist/validation/rules/external_concept_rule.rb
445
453
  - lib/glossarist/validation/rules/filename_id_rule.rb
446
454
  - lib/glossarist/validation/rules/gcr_context.rb
447
455
  - lib/glossarist/validation/rules/hyperedge_coherence_rule.rb