suma 0.5.7 → 0.5.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-benchmark.yml +25 -0
  3. data/.github/workflows/release-provenance.yml +41 -0
  4. data/.rubocop_todo.yml +84 -0
  5. data/CLAUDE.md +44 -0
  6. data/README.adoc +136 -11
  7. data/Rakefile +13 -1
  8. data/bin/benchmark_corpus +138 -0
  9. data/bin/release_provenance +40 -0
  10. data/lib/suma/build_plan.rb +71 -0
  11. data/lib/suma/change_classification.rb +37 -0
  12. data/lib/suma/change_impact.rb +61 -0
  13. data/lib/suma/change_manifest.rb +147 -0
  14. data/lib/suma/cli/build.rb +23 -0
  15. data/lib/suma/cli/compare.rb +49 -2
  16. data/lib/suma/cli/core.rb +83 -0
  17. data/lib/suma/cli/export.rb +140 -10
  18. data/lib/suma/cli/extract_terms.rb +11 -0
  19. data/lib/suma/cli/generate_register.rb +12 -0
  20. data/lib/suma/cli/resolve.rb +45 -0
  21. data/lib/suma/cli/validate.rb +90 -0
  22. data/lib/suma/cli.rb +1 -0
  23. data/lib/suma/closure_validator.rb +317 -0
  24. data/lib/suma/corpus_graph.rb +141 -0
  25. data/lib/suma/express_schema.rb +22 -28
  26. data/lib/suma/null_cache.rb +4 -0
  27. data/lib/suma/owl_projection.rb +611 -0
  28. data/lib/suma/processor.rb +55 -6
  29. data/lib/suma/provenance.rb +159 -0
  30. data/lib/suma/publication.rb +108 -0
  31. data/lib/suma/register_manifest_generator.rb +76 -8
  32. data/lib/suma/schema_cache.rb +72 -13
  33. data/lib/suma/schema_category.rb +31 -2
  34. data/lib/suma/schema_collection.rb +58 -5
  35. data/lib/suma/schema_exporter.rb +5 -5
  36. data/lib/suma/schema_naming.rb +29 -33
  37. data/lib/suma/schema_profile.rb +134 -0
  38. data/lib/suma/term_classification.rb +9 -2
  39. data/lib/suma/term_extractor.rb +76 -35
  40. data/lib/suma/urn_resolver.rb +368 -0
  41. data/lib/suma/version.rb +1 -1
  42. data/lib/suma/worker_pool.rb +62 -0
  43. data/lib/suma.rb +12 -0
  44. metadata +19 -2
@@ -42,11 +42,14 @@ module Suma
42
42
  annotations: false,
43
43
  create_zip: false,
44
44
  structure: :preserve,
45
+ jobs: 1,
45
46
  }
46
47
  end
47
48
 
48
49
  def export_to_directory(schemas)
49
- schemas.each { |schema| export_single_schema(schema) }
50
+ WorkerPool.new(size: options[:jobs]).map(schemas) do |schema|
51
+ export_single_schema(schema)
52
+ end
50
53
  end
51
54
 
52
55
  # Reuse cached plain/annotated output when the schema source is unchanged,
@@ -77,10 +80,7 @@ module Suma
77
80
  # (via the +:cache_dir+ option or the SUMA_SCHEMA_CACHE_DIR environment
78
81
  # variable), otherwise a null cache (caching disabled).
79
82
  def build_cache
80
- directory = options[:cache_dir] || ENV.fetch("SUMA_SCHEMA_CACHE_DIR", nil)
81
- return NullCache.new if directory.nil? || directory.empty?
82
-
83
- SchemaCache.new(directory)
83
+ SchemaCache.build(options)
84
84
  end
85
85
 
86
86
  # rubocop:disable-next Metrics/MethodLength
@@ -3,9 +3,12 @@
3
3
  module Suma
4
4
  # Converts EXPRESS schema identifiers into human-readable display names.
5
5
  #
6
- # Naming is model-driven: the schema type (resource/module) and suffix
7
- # determine how the identifier is formatted. Acronyms and numeric
8
- # prefixes are preserved to match ISO 10303 conventions.
6
+ # Naming is profile-driven: the profile declares the suffixes
7
+ # stripped from the identifier (with their parenthesised labels) and
8
+ # the acronyms preserved during title-casing. The ISO 10303 profile
9
+ # reproduces the historical behavior — ARM/MIM/BOM labels and the ISO
10
+ # acronym set; the generic profile strips nothing and applies plain
11
+ # title-casing.
9
12
  #
10
13
  # @example
11
14
  # SchemaNaming.display_name("topology_schema")
@@ -15,34 +18,19 @@ module Suma
15
18
  # SchemaNaming.display_name("aic_advanced_brep")
16
19
  # # => "AIC Advanced Brep"
17
20
  module SchemaNaming
18
- # Acronyms preserved as uppercase during title-casing.
19
- # Source: ISO 10303 naming conventions.
20
- ACRONYMS = %w[
21
- aic aec apu bom csg edraw id ifc pdf pld
22
- xml xpdl 2d 3d
23
- ].freeze
24
-
25
21
  # Lowercase function words (ISO title-case convention).
26
22
  LOWERCASE_WORDS = %w[a an and as for in of on or the to].freeze
27
23
 
28
- # Suffixes stripped from the schema name before title-casing,
29
- # mapped to the parenthesised label appended to the display name.
30
- # A +nil+ label means the suffix is stripped silently.
31
- SUFFIXES = {
32
- "_arm" => "ARM",
33
- "_mim" => "MIM",
34
- "_bom" => "BOM",
35
- "_schema" => nil,
36
- }.freeze
37
-
38
24
  class << self
39
25
  # Produce a human-readable display name from a schema identifier.
40
26
  #
41
27
  # @param schema_id [String] the EXPRESS schema identifier
28
+ # @param profile [SchemaProfile, Symbol, String, nil] schema profile
42
29
  # @return [String] human-readable name
43
- def display_name(schema_id)
44
- base, label = decompose(schema_id)
45
- title_cased = title_case(base)
30
+ def display_name(schema_id, profile: nil)
31
+ profile_obj = SchemaProfile.fetch(profile)
32
+ base, label = decompose(schema_id, profile_obj.suffix_labels)
33
+ title_cased = title_case(base, profile_obj)
46
34
  label ? "#{title_cased} (#{label})" : title_cased
47
35
  end
48
36
 
@@ -50,11 +38,13 @@ module Suma
50
38
  #
51
39
  # @param schema_id [String] the EXPRESS schema identifier
52
40
  # @param path [String, Pathname] the schema file path (for classification)
41
+ # @param profile [SchemaProfile, Symbol, String, nil] schema profile
53
42
  # @return [String] e.g. "Resource: Topology" or "Module: Activity (ARM)"
54
- def prefixed_name(schema_id, path: nil)
55
- type = ExpressSchema::Type.classify(id: schema_id, path: path)
43
+ def prefixed_name(schema_id, path: nil, profile: nil)
44
+ type = ExpressSchema::Type.classify(id: schema_id, path: path,
45
+ profile: profile)
56
46
  prefix = category_prefix(type)
57
- "#{prefix}: #{display_name(schema_id)}"
47
+ "#{prefix}: #{display_name(schema_id, profile: profile)}"
58
48
  end
59
49
 
60
50
  # Determine the category prefix for a schema type.
@@ -69,9 +59,11 @@ module Suma
69
59
 
70
60
  # Split a schema ID into (base_without_suffix, suffix_label_or_nil).
71
61
  #
62
+ # @param schema_id [String]
63
+ # @param suffix_labels [Hash{String => String, nil}]
72
64
  # @return [(String, String, nil)]
73
- def decompose(schema_id)
74
- SUFFIXES.each do |suffix, label|
65
+ def decompose(schema_id, suffix_labels)
66
+ suffix_labels.each do |suffix, label|
75
67
  if schema_id.end_with?(suffix)
76
68
  return [schema_id.delete_suffix(suffix),
77
69
  label]
@@ -86,11 +78,12 @@ module Suma
86
78
  # separator.
87
79
  #
88
80
  # @param name [String] snake_case identifier
81
+ # @param acronyms [Array<String>] lowercased words kept uppercase
89
82
  # @return [String] title-cased name
90
- def title_case(name)
83
+ def title_case(name, profile_obj)
91
84
  words = name.tr("_", " ").split
92
85
  words.each_with_index.map do |word, i|
93
- capitalize_word(word, first: i.zero?)
86
+ capitalize_word(word, profile_obj: profile_obj, first: i.zero?)
94
87
  end.join(" ")
95
88
  end
96
89
 
@@ -98,11 +91,14 @@ module Suma
98
91
  # lowercasing function words (except when first in the name).
99
92
  #
100
93
  # @param word [String]
94
+ # @param acronyms [Array<String>]
101
95
  # @param first [Boolean] whether this is the first word
102
96
  # @return [String]
103
- def capitalize_word(word, first: false)
104
- return word.upcase if ACRONYMS.include?(word.downcase)
105
- return word.downcase if LOWERCASE_WORDS.include?(word.downcase) && !first
97
+ def capitalize_word(word, profile_obj:, first: false)
98
+ lower = word.downcase
99
+ return word.upcase if profile_obj.acronyms.include?(lower)
100
+ return word.upcase if profile_obj.acronym_patterns.any? { |re| re.match?(lower) }
101
+ return word.downcase if LOWERCASE_WORDS.include?(lower) && !first
106
102
 
107
103
  word.capitalize
108
104
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Declares how a schema collection maps schema identifiers and paths
5
+ # to schema types, and how identifiers become display names.
6
+ #
7
+ # Suma works with any schema collection: ISO 10303 conventions (the
8
+ # `_arm`/`_mim` suffixes, the `resources/`+`modules/` directory tree,
9
+ # the ARM/MIM display labels) are one profile among many, not
10
+ # hardcoded behavior. A new collection's conventions are a new profile
11
+ # declaration — no changes to classification, naming, categories, or
12
+ # term extraction.
13
+ #
14
+ # Rules are ordered: the first matching rule classifies the schema.
15
+ # In the ISO 10303 profile the `/applications/` path rule precedes the
16
+ # suffix rules so an AP publication's `_arm`/`_mim` schemas classify as
17
+ # application protocols, while the same suffixes under `modules/`
18
+ # classify as before.
19
+ #
20
+ # @example
21
+ # SchemaProfile.fetch("iso10303").classify(
22
+ # id: "ap242_foo_mim", path: "applications/ap242/foo.exp"
23
+ # ) # => :application_protocol
24
+ # SchemaProfile.fetch("generic").classify(id: "x", path: "y.exp")
25
+ # # => :standalone
26
+ class SchemaProfile
27
+ # A single classification rule: +kind+ is :suffix (matched against the
28
+ # lowercased schema identifier) or :path (matched against the schema
29
+ # file path); +pattern+ is the string to match; +type+ is the
30
+ # ExpressSchema::Type symbol the rule produces.
31
+ Rule = Struct.new(:kind, :pattern, :type) do
32
+ def match(schema_id, schema_path)
33
+ case kind
34
+ when :suffix
35
+ schema_id&.downcase&.end_with?(pattern)
36
+ when :path
37
+ schema_path.to_s.include?(pattern)
38
+ end
39
+ end
40
+ end
41
+
42
+ attr_reader :id, :rules, :suffix_labels, :acronyms,
43
+ :acronym_patterns, :category_ids
44
+
45
+ # (a data-declaring initializer; six keywords by design)
46
+ def initialize(id:, rules: [], suffix_labels: {}, acronyms: [], # rubocop:disable Metrics/ParameterLists
47
+ acronym_patterns: [], category_ids: %w[other])
48
+ @id = id
49
+ @rules = rules.freeze
50
+ @suffix_labels = suffix_labels.freeze
51
+ @acronyms = acronyms.freeze
52
+ @acronym_patterns = acronym_patterns.freeze
53
+ @category_ids = category_ids.freeze
54
+ freeze
55
+ end
56
+
57
+ # Classify a schema under this profile.
58
+ #
59
+ # @param id [String, nil] schema identifier
60
+ # @param path [String, Pathname, nil] schema file path
61
+ # @return [Symbol] an ExpressSchema::Type value; +:standalone+ when no
62
+ # rule matches
63
+ def classify(id:, path:)
64
+ rules.each do |rule|
65
+ return rule.type if rule.match(id, path)
66
+ end
67
+ ExpressSchema::Type::STANDALONE
68
+ end
69
+
70
+ # The default (ISO 10303) profile.
71
+ #
72
+ # @return [SchemaProfile]
73
+ def self.default
74
+ fetch("iso10303")
75
+ end
76
+
77
+ # Look up a registered profile by id; +nil+ yields the default.
78
+ #
79
+ # @param id [String, Symbol, SchemaProfile, nil]
80
+ # @return [SchemaProfile]
81
+ # @raise [Suma::Error] on an unknown profile id
82
+ def self.fetch(id)
83
+ return id if id.is_a?(SchemaProfile)
84
+ return default if id.nil?
85
+
86
+ REGISTRY.fetch(id.to_s) do
87
+ raise Error, "[suma] unknown schema profile #{id.inspect} " \
88
+ "(registered: #{REGISTRY.keys.sort.join(', ')})"
89
+ end
90
+ end
91
+
92
+ ISO_10303 = new(
93
+ id: "iso10303",
94
+ rules: [
95
+ Rule.new(:path, "/applications/", :application_protocol),
96
+ Rule.new(:suffix, "_arm", :module_arm),
97
+ Rule.new(:suffix, "_mim", :module_mim),
98
+ Rule.new(:suffix, "_bom", :business_object_model),
99
+ Rule.new(:path, "/resources/", :resource),
100
+ Rule.new(:path, "/modules/", :module_arm),
101
+ Rule.new(:path, "/core_model/", :core_model),
102
+ ],
103
+ suffix_labels: {
104
+ "_arm" => "ARM",
105
+ "_mim" => "MIM",
106
+ "_bom" => "BOM",
107
+ "_schema" => nil,
108
+ },
109
+ acronyms: %w[
110
+ aic aec ap apu bom csg edraw id ifc pdf pld
111
+ xml xpdl 2d 3d
112
+ ],
113
+ # AP publications glue the number to the prefix: ap203, ap242, ...
114
+ acronym_patterns: [/\Aap\d+\z/],
115
+ category_ids: %w[
116
+ resources application_protocols modules
117
+ business_object_models core_model other
118
+ ],
119
+ )
120
+
121
+ GENERIC = new(
122
+ id: "generic",
123
+ rules: [],
124
+ suffix_labels: {},
125
+ acronyms: [],
126
+ category_ids: %w[other],
127
+ )
128
+
129
+ REGISTRY = {
130
+ "iso10303" => ISO_10303,
131
+ "generic" => GENERIC,
132
+ }.freeze
133
+ end
134
+ end
@@ -36,6 +36,12 @@ module Suma
36
36
  entity_term: "express-language.entity_data_type",
37
37
  entity_display: "entity data type",
38
38
  ),
39
+ ExpressSchema::Type::APPLICATION_PROTOCOL => new(
40
+ type: ExpressSchema::Type::APPLICATION_PROTOCOL,
41
+ domain_label: "application protocol",
42
+ entity_term: "express-language.entity_data_type",
43
+ entity_display: "entity data type",
44
+ ),
39
45
  ExpressSchema::Type::MODULE_ARM => new(
40
46
  type: ExpressSchema::Type::MODULE_ARM,
41
47
  domain_label: "application module",
@@ -68,8 +74,9 @@ module Suma
68
74
  ),
69
75
  }.freeze
70
76
 
71
- def self.for_schema(id:, path:)
72
- type = ExpressSchema::Type.classify(id: id, path: path)
77
+ def self.for_schema(id:, path:, profile: nil)
78
+ type = ExpressSchema::Type.classify(id: id, path: path,
79
+ profile: profile)
73
80
  BY_TYPE.fetch(type) do |t|
74
81
  raise Error, "[suma] no term classification for type #{t.inspect}"
75
82
  end
@@ -7,11 +7,15 @@ require "glossarist"
7
7
  module Suma
8
8
  class TermExtractor
9
9
  def initialize(schema_manifest_file, output_path, urn:,
10
- language_code: "eng")
10
+ language_code: "eng", profile: nil, edition: nil,
11
+ language_codes: nil)
11
12
  @schema_manifest_file = File.expand_path(schema_manifest_file)
12
13
  @output_path = output_path
13
14
  @language_code = language_code
15
+ @language_codes = normalize_languages(language_code, language_codes)
14
16
  @urn = Suma::Urn.new(urn)
17
+ @profile = SchemaProfile.fetch(profile)
18
+ @edition = edition
15
19
  end
16
20
 
17
21
  def call
@@ -23,6 +27,12 @@ module Suma
23
27
 
24
28
  private
25
29
 
30
+ # The primary language is always first; additional languages produce
31
+ # linked localizations of the same managed concept.
32
+ def normalize_languages(primary, extra)
33
+ ([primary] + Array(extra).map(&:to_s)).map(&:to_s).uniq
34
+ end
35
+
26
36
  def validate_inputs
27
37
  unless File.exist?(@schema_manifest_file)
28
38
  raise Errno::ENOENT, "Specified SCHEMA_MANIFEST_FILE " \
@@ -56,7 +66,8 @@ module Suma
56
66
  raise Error, "Schema must have an associated file" unless schema.file
57
67
 
58
68
  classification = TermClassification.for_schema(id: schema.id,
59
- path: schema.file)
69
+ path: schema.file,
70
+ profile: @profile)
60
71
  collection = build_managed_concept_collection(schema, classification)
61
72
  output_data(collection)
62
73
  collection
@@ -80,30 +91,12 @@ module Suma
80
91
  end
81
92
 
82
93
  def build_managed_concept_collection(schema, classification)
83
- source_ref = get_source_ref(schema)
84
94
  section_ref = get_section_ref(schema)
85
95
 
86
96
  Glossarist::ManagedConceptCollection.new.tap do |collection|
87
97
  schema.entities.each do |entity|
88
- localized_concept_id = Glossarist::Utilities::UUID.uuid_v5(
89
- Glossarist::Utilities::UUID::OID_NAMESPACE,
90
- "#{schema.id}.#{entity.id}-#{@language_code}",
91
- )
92
-
93
- localized_concept = build_localized_concept(
94
- schema: schema,
95
- entity: entity,
96
- source_ref: source_ref,
97
- uuid: localized_concept_id,
98
- classification: classification,
99
- )
100
-
101
- managed_data = Glossarist::V3::ManagedConceptData.new.tap do |data|
102
- data.id = "#{schema.id}.#{entity.id}"
103
- data.localizations.store(@language_code, localized_concept)
104
- data.localized_concepts = { @language_code => localized_concept_id }
105
- data.domains = [section_ref] if section_ref
106
- end
98
+ managed_data = build_managed_data(schema, entity, section_ref,
99
+ classification)
107
100
 
108
101
  managed_concept = Glossarist::V3::ManagedConcept.new.tap do |concept|
109
102
  concept.id = managed_data.id
@@ -120,16 +113,45 @@ module Suma
120
113
  end
121
114
  end
122
115
 
123
- def build_localized_concept(schema:, entity:, source_ref:, uuid:,
116
+ def build_managed_data(schema, entity, section_ref, classification)
117
+ Glossarist::V3::ManagedConceptData.new.tap do |data|
118
+ data.id = "#{schema.id}.#{entity.id}"
119
+ data.localized_concepts = {}
120
+
121
+ @language_codes.each do |language|
122
+ localized_id = localized_concept_id(schema, entity, language)
123
+ localized = build_localized_concept(
124
+ schema: schema,
125
+ entity: entity,
126
+ language: language,
127
+ uuid: localized_id,
128
+ classification: classification,
129
+ )
130
+ data.localizations.store(language, localized)
131
+ data.localized_concepts[language] = localized_id
132
+ end
133
+
134
+ data.domains = [section_ref] if section_ref
135
+ end
136
+ end
137
+
138
+ def localized_concept_id(schema, entity, language)
139
+ Glossarist::Utilities::UUID.uuid_v5(
140
+ Glossarist::Utilities::UUID::OID_NAMESPACE,
141
+ "#{schema.id}.#{entity.id}-#{language}",
142
+ )
143
+ end
144
+
145
+ def build_localized_concept(schema:, entity:, language:, uuid:,
124
146
  classification:)
125
147
  schema_domain = classification.domain_for(schema.id)
126
148
 
127
149
  localized_concept_data = Glossarist::V3::ConceptData.new.tap do |data|
128
150
  data.terms = get_entity_terms(entity)
129
151
  data.definition = get_entity_definitions(entity, schema, classification)
130
- data.language_code = @language_code
152
+ data.language_code = language
131
153
  data.domain = schema_domain
132
- data.sources = [source_ref] if source_ref
154
+ data.sources = [build_source_ref(schema, entity)]
133
155
 
134
156
  notes = NoteProcessor.call(
135
157
  entity.remarks,
@@ -175,26 +197,45 @@ module Suma
175
197
  )
176
198
  end
177
199
 
178
- def get_source_ref(schema)
200
+ # Provenance: the schema is the authoritative source, the entity URN is
201
+ # what the concept is about, and the edition/version identifies the
202
+ # owning release.
203
+ def build_source_ref(schema, entity)
179
204
  ref = Glossarist::Citation::Ref.new
180
205
  ref.source = schema_urn(schema)
181
-
182
- build_custom_locality(schema).each do |cl|
183
- case cl.name
184
- when "version" then ref.version = cl.value
185
- end
186
- end
206
+ ref.version = edition_label(schema)
187
207
 
188
208
  origin = Glossarist::V3::Citation.new
189
209
  origin.ref = ref
190
- Glossarist::V3::ConceptSource.new(id: schema.id, type: "authoritative",
191
- origin: origin)
210
+ Glossarist::V3::ConceptSource.new(
211
+ id: schema.id,
212
+ type: "authoritative",
213
+ origin: origin,
214
+ sourced_from: [entity_citation(schema, entity)],
215
+ )
216
+ end
217
+
218
+ def entity_citation(schema, entity)
219
+ ref = Glossarist::Citation::Ref.new
220
+ ref.source = express_entity_urn("#{schema.id}.#{entity.id}")
221
+ ref.version = edition_label(schema)
222
+
223
+ Glossarist::V3::Citation.new.tap { |citation| citation.ref = ref }
224
+ end
225
+
226
+ def edition_label(schema)
227
+ return @edition.to_s if @edition
228
+
229
+ build_custom_locality(schema)
230
+ .find { |locality| locality.name == "version" }&.value
192
231
  end
193
232
 
194
233
  def build_custom_locality(schema)
195
234
  localities = []
196
235
 
197
- version_item = schema.version.items.detect { |i| i.name == "version" }
236
+ version_item = Array(schema.version&.items).detect do |item|
237
+ item.name == "version"
238
+ end
198
239
  if version_item
199
240
  localities << Glossarist::CustomLocality.new(name: "version",
200
241
  value: version_item.value)