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.
- checksums.yaml +4 -4
- data/config.yml +15 -0
- data/lib/glossarist/cli/export_command.rb +5 -5
- data/lib/glossarist/concept_ref.rb +49 -0
- data/lib/glossarist/gcr_package.rb +2 -2
- data/lib/glossarist/gcr_validator.rb +19 -1
- data/lib/glossarist/glossary_definition.rb +16 -3
- data/lib/glossarist/glossary_store.rb +70 -0
- data/lib/glossarist/managed_concept.rb +2 -2
- data/lib/glossarist/rdf/gloss_concept.rb +3 -0
- data/lib/glossarist/rdf/gloss_generic_member.rb +34 -0
- data/lib/glossarist/rdf/gloss_generic_relation.rb +44 -0
- data/lib/glossarist/rdf/gloss_nary_member.rb +43 -0
- data/lib/glossarist/rdf/gloss_nary_relation.rb +56 -0
- data/lib/glossarist/rdf/gloss_partitive_member.rb +5 -28
- data/lib/glossarist/rdf/gloss_partitive_relation.rb +6 -19
- data/lib/glossarist/rdf.rb +4 -0
- data/lib/glossarist/tasks/shacl.rake +4 -1
- data/lib/glossarist/tasks/sync.rake +4 -1
- data/lib/glossarist/tasks.rb +9 -0
- data/lib/glossarist/transforms/concept_to_gloss_transform.rb +72 -52
- data/lib/glossarist/v3/abstract_hyperedge.rb +187 -0
- data/lib/glossarist/v3/definition_type.rb +30 -0
- data/lib/glossarist/v3/detailed_definition.rb +4 -0
- data/lib/glossarist/v3/generic_hyperedge.rb +34 -0
- data/lib/glossarist/v3/generic_member.rb +17 -0
- data/lib/glossarist/v3/hyperedge_index.rb +89 -0
- data/lib/glossarist/v3/hyperedge_member.rb +97 -0
- data/lib/glossarist/v3/hyperedge_registry.rb +70 -0
- data/lib/glossarist/v3/hyperedge_writer.rb +87 -0
- data/lib/glossarist/v3/managed_concept.rb +53 -2
- data/lib/glossarist/v3/managed_concept_data.rb +0 -1
- data/lib/glossarist/v3/partitive_hyperedge.rb +53 -0
- data/lib/glossarist/v3/partitive_member.rb +8 -105
- data/lib/glossarist/v3/relation_loader.rb +98 -0
- data/lib/glossarist/v3.rb +25 -8
- data/lib/glossarist/validation/rules/concept_context.rb +10 -2
- data/lib/glossarist/validation/rules/{partitive_relation_rule.rb → hyperedge_coherence_rule.rb} +22 -16
- data/lib/glossarist/version.rb +1 -1
- data/lib/glossarist.rb +1 -0
- metadata +17 -3
- 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
|
|
@@ -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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
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 :
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|