glossarist 2.13.4 → 2.13.6

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: ebf6ec1d9cc3f2c1932fff4d389b19e60dff4846e3f82167fa6e13bef8347f7d
4
- data.tar.gz: 2aee9b19e15588b9fc74a973d02406de96b1ed01ebefcdd7b87bc472d3b8d53b
3
+ metadata.gz: 51ac508f50dca0e1c04b8a37fbb75460dc27496876321361b9e3691995b203dc
4
+ data.tar.gz: a41b2f29cfaca14931ee3035c751ad296065a0159b9bc202251e1775973f3ea8
5
5
  SHA512:
6
- metadata.gz: '09eeb3261b6e753a4e1690496f13db04053429318108d52a031963087a9c908f842a12802b310014874f78f5d47bfd3a54dd1672e610e0b55684133a82ec2331'
7
- data.tar.gz: de663d1b6fe2581a17ae52e2fb69d2ea6accf6ca46f92be084ec603c5b8f0f3d614a5dbeaf5a20178bb8d08511ea046210ed09a183c9c35b0f9d462b0dcb62d6
6
+ metadata.gz: 5a9a6b0040e89f986f676a2dbd4b8c86244c110fb4f3c005f9d5995f3bdf7abef50c48564c5d8551d11beca5f7876def2b8970ff719b086e091562f6ce3830ae
7
+ data.tar.gz: 4f81856d110124d519e3cbf10a5863623fe13b57283e89861ce1882c83d7fb1768c792c0f27e41b99c4cd7c462b00ce0a7dcfb894169c92985c4430c4fa8b84a
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # Deterministic slug helper for RDF subject URIs.
8
+ #
9
+ # When an entity has no natural-language identifier (no source/id
10
+ # pair, no text), derive a deterministic short slug from its
11
+ # content fingerprint. Same content → same slug across runs and
12
+ # across processes; different content → different slug (modulo
13
+ # collision probability).
14
+ #
15
+ # Hash choice:
16
+ # - SHA-256 (not MD5). MD5 is deprecated for new code; SHA-256
17
+ # is the modern baseline and is free performance-wise.
18
+ # - Truncated to 16 hex chars (64 bits). Birthday-paradox
19
+ # collision probability at 4 million items < 1e-9, comfortable
20
+ # margin for any realistic dataset.
21
+ #
22
+ # Previous width was 12 chars (48 bits) using MD5 — collision
23
+ # probability at 100k items was ~0.3%, too high for RDF merge.
24
+ module DeterministicSlug
25
+ HASH_WIDTH = 16
26
+
27
+ def self.from_parts(*parts)
28
+ joined = parts.compact.join("|")
29
+ Digest::SHA256.hexdigest(joined)[0, HASH_WIDTH]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -32,7 +32,7 @@ module Glossarist
32
32
 
33
33
  def self.slug(citation)
34
34
  slug = [citation.source, citation.id].compact.join("/")
35
- slug = Digest::MD5.hexdigest(citation.source || "")[0..11] if slug.empty?
35
+ slug = DeterministicSlug.from_parts(citation.source) if slug.empty?
36
36
  slug
37
37
  end
38
38
  end
@@ -38,7 +38,7 @@ module Glossarist
38
38
  Array(source.sourced_from).each do |sf|
39
39
  parts << sf&.source << sf&.id << sf&.version << sf&.link
40
40
  end
41
- Digest::MD5.hexdigest(parts.compact.join("|"))[0..11]
41
+ DeterministicSlug.from_parts(*parts)
42
42
  end
43
43
  end
44
44
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "lutaml/model"
5
4
 
6
5
  module Glossarist
@@ -24,7 +23,7 @@ module Glossarist
24
23
  end
25
24
 
26
25
  def self.deterministic_id(definition)
27
- Digest::MD5.hexdigest(definition.content.to_s)[0..11]
26
+ DeterministicSlug.from_parts(definition.content)
28
27
  end
29
28
  end
30
29
  end
@@ -4,9 +4,16 @@ require "lutaml/model"
4
4
 
5
5
  module Glossarist
6
6
  module Rdf
7
- # RDF view for V3::PartitiveMember. Emits a
8
- # gloss:PartitiveMember subject with ref, presence, count, and
9
- # is_delimiting properties.
7
+ # RDF view for V3::PartitiveMember. Emits a gloss:PartitiveMember
8
+ # subject carrying the ConceptRef target plus the ISO 704:2022
9
+ # orthogonal dimensions (presence, count, is_delimiting).
10
+ #
11
+ # Subject identity is content-derived so the same member in the
12
+ # same relation produces the same URI across runs (and across
13
+ # processes). Two members with identical ref + dimensions share a
14
+ # subject — that is intentional: it exposes model-level
15
+ # inconsistency if the same partitive concept is given different
16
+ # dimensions inside the same dataset.
10
17
  class GlossPartitiveMember < Lutaml::Model::Serializable
11
18
  attribute :ref_id, :string
12
19
  attribute :ref_source, :string
@@ -18,7 +25,7 @@ module Glossarist
18
25
  rdf do
19
26
  namespace Namespaces::GlossaristNamespace
20
27
 
21
- subject { |m| "partitiveMember/#{m.ref_source}:#{m.ref_id}" }
28
+ subject { |m| "partitiveMember/#{GlossPartitiveMember.deterministic_id(m)}" }
22
29
 
23
30
  types "gloss:PartitiveMember"
24
31
 
@@ -35,6 +42,16 @@ module Glossarist
35
42
  predicate :isDelimiting, namespace: Namespaces::GlossaristNamespace,
36
43
  to: :is_delimiting
37
44
  end
45
+
46
+ def self.deterministic_id(member)
47
+ readable = [member.ref_source, member.ref_id].compact.reject(&:empty?)
48
+ return readable.join(":") unless readable.empty?
49
+
50
+ DeterministicSlug.from_parts(
51
+ member.ref_source, member.ref_id, member.ref_text,
52
+ member.presence, member.count, member.is_delimiting
53
+ )
54
+ end
38
55
  end
39
56
  end
40
57
  end
@@ -5,19 +5,22 @@ require "lutaml/model"
5
5
  module Glossarist
6
6
  module Rdf
7
7
  # RDF view for V3::PartitiveRelation. Emits a
8
- # gloss:PartitiveRelation subject with comprehensive, hasPartitive,
9
- # completeness, criterion properties.
8
+ # gloss:PartitiveRelation subject with comprehensive, completeness,
9
+ # criterion properties, plus hasPartitive (concept URIs, for graph
10
+ # traversal) and hasPartitiveMember (typed subjects carrying the
11
+ # ISO 704:2022 per-member dimensions).
10
12
  class GlossPartitiveRelation < Lutaml::Model::Serializable
11
13
  attribute :identifier, :string
12
14
  attribute :comprehensive_uri, :string
13
15
  attribute :partitive_member_ids, :string, collection: true
16
+ attribute :partitive_members, GlossPartitiveMember, collection: true
14
17
  attribute :completeness, :string
15
18
  attribute :criterion, :hash
16
19
 
17
20
  rdf do
18
21
  namespace Namespaces::GlossaristNamespace
19
22
 
20
- subject { |r| "partitiveRelation/#{r.identifier}/#{r.object_id}" }
23
+ subject { |r| "partitiveRelation/#{GlossPartitiveRelation.deterministic_id(r)}" }
21
24
 
22
25
  types "gloss:PartitiveRelation"
23
26
 
@@ -29,6 +32,25 @@ module Glossarist
29
32
  to: :completeness, uri_reference: true
30
33
  predicate :criterion, namespace: Namespaces::GlossaristNamespace,
31
34
  to: :criterion
35
+
36
+ members :partitive_members, link: "gloss:hasPartitiveMember"
37
+ end
38
+
39
+ def self.deterministic_id(rel)
40
+ parts = [rel.identifier, rel.comprehensive_uri, rel.completeness,
41
+ criterion_fingerprint(rel.criterion)]
42
+ Array(rel.partitive_members).each do |m|
43
+ parts << GlossPartitiveMember.deterministic_id(m)
44
+ end
45
+ DeterministicSlug.from_parts(*parts)
46
+ end
47
+
48
+ def self.criterion_fingerprint(criterion)
49
+ return nil unless criterion.is_a?(Hash) && !criterion.empty?
50
+
51
+ criterion.sort_by { |k, _| k.to_s }
52
+ .map { |k, v| "#{k}=#{v}" }
53
+ .join(";")
32
54
  end
33
55
  end
34
56
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
- require "digest"
5
4
 
6
5
  module Glossarist
7
6
  module Rdf
@@ -47,7 +46,7 @@ module Glossarist
47
46
 
48
47
  def self.slug(ref)
49
48
  slug = [ref.source, ref.id].compact.join("/")
50
- slug = Digest::MD5.hexdigest(ref.text || "")[0..11] if slug.empty?
49
+ slug = DeterministicSlug.from_parts(ref.text) if slug.empty?
51
50
  slug
52
51
  end
53
52
  end
@@ -17,6 +17,7 @@ module Glossarist
17
17
  autoload :LutamlTurtleTransformExt, "#{__dir__}/rdf/lutaml_ext"
18
18
 
19
19
  autoload :Namespaces, "#{__dir__}/rdf/namespaces"
20
+ autoload :DeterministicSlug, "#{__dir__}/rdf/deterministic_slug"
20
21
  autoload :LocalizedLiteral, "#{__dir__}/rdf/localized_literal"
21
22
  autoload :RelationshipPredicates, "#{__dir__}/rdf/relationship_predicates"
22
23
  autoload :GlossLocality, "#{__dir__}/rdf/gloss_locality"
@@ -131,7 +131,7 @@ module Glossarist
131
131
  identifier),
132
132
  dates: build_gloss_dates(managed_concept.dates, identifier),
133
133
  partitive_relations: build_gloss_partitive_relations(
134
- v3_partitive_relations(managed_concept), identifier,
134
+ v3_partitive_relations(managed_concept), identifier
135
135
  ),
136
136
  **rel_targets,
137
137
  )
@@ -150,33 +150,54 @@ module Glossarist
150
150
  return [] unless relations
151
151
 
152
152
  Array(relations).map do |rel|
153
+ source_members = Array(rel.partitives)
154
+ gloss_members = source_members.map { |m| build_gloss_partitive_member(m) }
155
+ member_uris = source_members
156
+ .select { |m| m.is_a?(V3::PartitiveMember) && m.ref.is_a?(Glossarist::ConceptRef) }
157
+ .map { |m| concept_ref_uri(m.ref) }
158
+
153
159
  Rdf::GlossPartitiveRelation.new(
154
160
  identifier: identifier.to_s,
155
- comprehensive_uri: partitive_concept_uri(rel.comprehensive),
156
- partitive_member_ids: Array(rel.partitives).map do |m|
157
- partitive_member_uri(m)
158
- end,
161
+ comprehensive_uri: concept_ref_uri(rel.comprehensive),
162
+ partitive_member_ids: member_uris,
163
+ partitive_members: gloss_members,
159
164
  completeness: rel.completeness,
160
165
  criterion: rel.criterion,
161
166
  )
162
167
  end
163
168
  end
164
169
 
165
- def partitive_concept_uri(ref)
166
- return nil unless ref.is_a?(Glossarist::ConceptRef)
170
+ def build_gloss_partitive_member(member)
171
+ return Rdf::GlossPartitiveMember.new unless member.is_a?(V3::PartitiveMember)
167
172
 
168
- Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
169
- "concept/#{ref.id || ref.text}"
173
+ ref = member.ref
174
+ ref_attrs = if ref.is_a?(Glossarist::ConceptRef)
175
+ { ref_id: ref.id, ref_source: ref.source, ref_text: ref.text }
176
+ else
177
+ {}
178
+ end
179
+
180
+ Rdf::GlossPartitiveMember.new(
181
+ **ref_attrs,
182
+ presence: member.presence,
183
+ count: member.count,
184
+ is_delimiting: member.is_delimiting,
185
+ )
170
186
  end
171
187
 
172
- def partitive_member_uri(member)
173
- return nil unless member.is_a?(V3::PartitiveMember)
174
-
175
- ref = member.ref
188
+ # Single SSOT for ConceptRef → concept URI. Handles all four
189
+ # ref shapes (source+id, id-only, source+text, text-only) and
190
+ # mirrors GlossCitation.slug's `[source, id].compact.join("/")`
191
+ # convention so external ConceptRefs don't collide with local
192
+ # ones of the same id.
193
+ def concept_ref_uri(ref)
176
194
  return nil unless ref.is_a?(Glossarist::ConceptRef)
177
195
 
196
+ slug = [ref.source, ref.id || ref.text].compact.reject(&:empty?)
197
+ return nil if slug.empty?
198
+
178
199
  Glossarist::Rdf::Namespaces::GlossaristNamespace.uri +
179
- "concept/#{ref.id || ref.text}"
200
+ "concept/#{slug.join('/')}"
180
201
  end
181
202
 
182
203
  def build_gloss_localized_concept(l10n, concept_id)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module V3
5
+ # Multiplicity — ISO 704:2022 partitive multiplicity (derived view).
6
+ #
7
+ # ISO 704:2022 multiplicity is the cross product of two orthogonal
8
+ # dimensions (PartitivePresence × PartitiveCount). The 5 valid
9
+ # combinations map to the 5 ISO 704 names:
10
+ #
11
+ # required × exactly_one → compulsory
12
+ # optional × exactly_one → optional
13
+ # required × multiple → compulsory_multiple
14
+ # optional × multiple → optional_multiple
15
+ # required × at_least_one → compulsory_at_least_one
16
+ #
17
+ # (optional × at_least_one is REDUNDANT with optional × multiple —
18
+ # "may exist, ≥1 if it does" = "may exist in any number" — and is
19
+ # explicitly rejected at construction with a clear error message.)
20
+ #
21
+ # This module is the SSOT for the (presence, count) → ISO name
22
+ # mapping. Both MULTIPLICITY constants and MULTIPLICITY_VALUES are
23
+ # derived from this single table; no duplicate string literals.
24
+ module Multiplicity
25
+ NAME_BY_PAIR = {
26
+ %w[required exactly_one] => "compulsory",
27
+ %w[optional exactly_one] => "optional",
28
+ %w[required multiple] => "compulsory_multiple",
29
+ %w[optional multiple] => "optional_multiple",
30
+ %w[required at_least_one] => "compulsory_at_least_one",
31
+ }.freeze
32
+
33
+ # Precomputed reverse map: ISO name → (presence, count) pair.
34
+ # Avoids NAME_BY_PAIR.find for O(1) reverse lookup. Mirrors the
35
+ # glossarist-js PAIR_BY_NAME so the JS and Ruby MECE derivation
36
+ # stay in lockstep.
37
+ PAIR_BY_NAME = NAME_BY_PAIR.each_with_object({}) do |(pair, name), h|
38
+ h[name] = pair
39
+ end.freeze
40
+
41
+ MULTIPLICITY = NAME_BY_PAIR.values.to_h do |name|
42
+ [name.upcase.to_sym, name]
43
+ end.freeze
44
+
45
+ MULTIPLICITY_VALUES = NAME_BY_PAIR.values.freeze
46
+ DEFAULT_MULTIPLICITY = MULTIPLICITY[:COMPULSORY]
47
+
48
+ def self.multiplicity_from_pair(presence, count)
49
+ name = NAME_BY_PAIR[[presence, count]]
50
+ raise invalid_combination_error(presence, count) if name.nil?
51
+
52
+ name
53
+ end
54
+
55
+ def self.pair_from_multiplicity(name)
56
+ pair = PAIR_BY_NAME[name]
57
+ raise ArgumentError, "Unknown multiplicity: #{name.inspect}" if pair.nil?
58
+
59
+ { presence: pair[0], count: pair[1] }
60
+ end
61
+
62
+ def self.valid_multiplicity?(name)
63
+ PAIR_BY_NAME.key?(name)
64
+ end
65
+
66
+ def self.invalid_combination_error(presence, count)
67
+ ArgumentError.new(
68
+ "Invalid multiplicity combination: " \
69
+ "presence=#{presence}, count=#{count}. " \
70
+ "(optional + at_least_one collapses to " \
71
+ "optional + multiple — use count=multiple.)",
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -74,20 +74,6 @@ module Glossarist
74
74
  is_delimiting == true
75
75
  end
76
76
 
77
- # Derived ISO 704 name for display. Returns the flat-enum
78
- # name that corresponds to this (presence, count) pair.
79
- def iso704_name
80
- case [presence, count]
81
- when ["required", "exactly_one"] then "compulsory"
82
- when ["optional", "exactly_one"] then "optional"
83
- when ["required", "multiple"] then "compulsory_multiple"
84
- when ["optional", "multiple"] then "optional_multiple"
85
- when ["required", "at_least_one"] then "compulsory_at_least_one"
86
- else raise ArgumentError,
87
- "invalid combination presence=#{presence} count=#{count}"
88
- end
89
- end
90
-
91
77
  private
92
78
 
93
79
  def validate_ref!
data/lib/glossarist/v3.rb CHANGED
@@ -11,6 +11,13 @@ module Glossarist
11
11
  autoload :RelatedConcept, "glossarist/v3/related_concept"
12
12
  autoload :PartitiveRelation, "glossarist/v3/partitive_relation"
13
13
  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.
20
+ autoload :Multiplicity, "glossarist/v3/multiplicity"
14
21
  autoload :ConceptData, "glossarist/v3/concept_data"
15
22
  autoload :LocalizedConcept, "glossarist/v3/localized_concept"
16
23
  autoload :ManagedConceptData, "glossarist/v3/managed_concept_data"
@@ -14,14 +14,19 @@ module Glossarist
14
14
  # ISO 12620 coordinate-concept coherence)
15
15
  # - warning when a relation has no criterion (cannot
16
16
  # distinguish from siblings sharing the comprehensive)
17
- # - warning when multiplicity is not the default compulsory
18
- # (encourages explicit statement of optional/multiple)
17
+ # - warning when a member's presence or count deviates from
18
+ # the defaults (required / exactly_one) encourages an
19
+ # explicit choice rather than an accidental non-default
19
20
  # - error when ExternalConcept (status: external) lacks
20
21
  # at least one designation
21
22
  #
22
23
  # The model constructor already rejects empty comprehensive,
23
- # empty partitives list, self-loops, invalid enum values.
24
+ # empty partitives list, self-loops, invalid enum values, and
25
+ # the optional + at_least_one combination.
24
26
  class PartitiveRelationRule < Base
27
+ DEFAULT_PRESENCE = "required"
28
+ DEFAULT_COUNT = "exactly_one"
29
+
25
30
  def code = "GLS-221"
26
31
  def category = :schema
27
32
  def severity = "error"
@@ -46,7 +51,7 @@ module Glossarist
46
51
  relations.each_with_index do |rel, idx|
47
52
  check_cardinality(rel, idx, fname, issues)
48
53
  check_criterion_present(rel, idx, fname, issues)
49
- check_member_multiplicity(rel, idx, fname, issues)
54
+ check_member_dimensions(rel, idx, fname, issues)
50
55
  end
51
56
 
52
57
  check_duplicate_decomposition(relations, fname, issues)
@@ -85,28 +90,28 @@ module Glossarist
85
90
  )
86
91
  end
87
92
 
88
- def check_member_multiplicity(rel, idx, fname, issues)
93
+ # Warns once per member when presence or count deviates from
94
+ # the model defaults. The warning is intentionally generic —
95
+ # it fires for required+multiple (non-default count) just as
96
+ # for optional+exactly_one (non-default presence). Both are
97
+ # legal; the warning encourages an explicit, reviewed choice
98
+ # rather than an accidental non-default.
99
+ def check_member_dimensions(rel, idx, fname, issues)
89
100
  rel.partitives.each_with_index do |member, mi|
90
- unless member.required? && member.count == "exactly_one"
91
- issues << issue(
92
- "partitive_relation #{idx + 1}.partitives[#{mi}] has " \
93
- "non-default presence='#{member.presence}' count='#{member.count}' " \
94
- "(ISO 704 name: #{member.iso704_name}); confirm the " \
95
- "optionality is intentional",
96
- severity: "warning",
97
- location: fname,
98
- )
99
- end
100
-
101
- next unless member.delimiting?
101
+ non_default = []
102
+ non_default << "presence='#{member.presence}'" if member.presence != DEFAULT_PRESENCE
103
+ non_default << "count='#{member.count}'" if member.count != DEFAULT_COUNT
104
+ next if non_default.empty?
102
105
 
103
106
  issues << issue(
104
- "partitive_relation #{idx + 1}.partitives[#{mi}] is marked " \
105
- "delimiting (ISO 704 bold 3x-width line); behaves like a " \
106
- "delimiting characteristic distinguishing the comprehensive " \
107
- "from coordinate concepts",
108
- severity: "info",
107
+ "partitive_relation #{idx + 1}.partitives[#{mi}] uses " \
108
+ "non-default #{non_default.join(' ')} " \
109
+ "(defaults: presence=#{DEFAULT_PRESENCE}, count=#{DEFAULT_COUNT}) " \
110
+ " confirm the dimensions are intentional",
111
+ severity: "warning",
109
112
  location: fname,
113
+ suggestion: "Non-default dimensions are valid; this warning " \
114
+ "exists to catch accidental drift from the model defaults.",
110
115
  )
111
116
  end
112
117
  end
@@ -4,5 +4,5 @@
4
4
  #
5
5
 
6
6
  module Glossarist
7
- VERSION = "2.13.4"
7
+ VERSION = "2.13.6"
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.4
4
+ version: 2.13.6
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-28 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -296,6 +296,7 @@ files:
296
296
  - lib/glossarist/non_verbal_reference.rb
297
297
  - lib/glossarist/pronunciation.rb
298
298
  - lib/glossarist/rdf.rb
299
+ - lib/glossarist/rdf/deterministic_slug.rb
299
300
  - lib/glossarist/rdf/gloss_citation.rb
300
301
  - lib/glossarist/rdf/gloss_concept.rb
301
302
  - lib/glossarist/rdf/gloss_concept_date.rb
@@ -396,6 +397,7 @@ files:
396
397
  - lib/glossarist/v3/localized_concept.rb
397
398
  - lib/glossarist/v3/managed_concept.rb
398
399
  - lib/glossarist/v3/managed_concept_data.rb
400
+ - lib/glossarist/v3/multiplicity.rb
399
401
  - lib/glossarist/v3/partitive_member.rb
400
402
  - lib/glossarist/v3/partitive_relation.rb
401
403
  - lib/glossarist/v3/related_concept.rb