suma 0.5.7 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-benchmark.yml +25 -0
  3. data/.github/workflows/release-provenance.yml +38 -0
  4. data/.rubocop_todo.yml +84 -0
  5. data/CLAUDE.md +44 -0
  6. data/Rakefile +13 -1
  7. data/bin/benchmark_corpus +138 -0
  8. data/bin/release_provenance +40 -0
  9. data/lib/suma/build_plan.rb +71 -0
  10. data/lib/suma/change_classification.rb +37 -0
  11. data/lib/suma/change_impact.rb +61 -0
  12. data/lib/suma/change_manifest.rb +147 -0
  13. data/lib/suma/cli/build.rb +23 -0
  14. data/lib/suma/cli/compare.rb +49 -2
  15. data/lib/suma/cli/core.rb +83 -0
  16. data/lib/suma/cli/export.rb +140 -10
  17. data/lib/suma/cli/extract_terms.rb +11 -0
  18. data/lib/suma/cli/generate_register.rb +12 -0
  19. data/lib/suma/cli/resolve.rb +45 -0
  20. data/lib/suma/cli/validate.rb +90 -0
  21. data/lib/suma/cli.rb +1 -0
  22. data/lib/suma/closure_validator.rb +317 -0
  23. data/lib/suma/corpus_graph.rb +141 -0
  24. data/lib/suma/express_schema.rb +22 -28
  25. data/lib/suma/null_cache.rb +4 -0
  26. data/lib/suma/owl_projection.rb +611 -0
  27. data/lib/suma/processor.rb +55 -6
  28. data/lib/suma/provenance.rb +159 -0
  29. data/lib/suma/publication.rb +108 -0
  30. data/lib/suma/register_manifest_generator.rb +76 -8
  31. data/lib/suma/schema_cache.rb +72 -13
  32. data/lib/suma/schema_category.rb +31 -2
  33. data/lib/suma/schema_collection.rb +58 -5
  34. data/lib/suma/schema_exporter.rb +5 -5
  35. data/lib/suma/schema_naming.rb +29 -33
  36. data/lib/suma/schema_profile.rb +134 -0
  37. data/lib/suma/term_classification.rb +9 -2
  38. data/lib/suma/term_extractor.rb +76 -35
  39. data/lib/suma/urn_resolver.rb +318 -0
  40. data/lib/suma/version.rb +1 -1
  41. data/lib/suma/worker_pool.rb +62 -0
  42. data/lib/suma.rb +12 -0
  43. metadata +19 -2
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "expressir"
6
+ require "glossarist"
7
+ # Expressir::SchemaManifest references Pathname without requiring it.
8
+ require "pathname"
9
+
10
+ module Suma
11
+ # Resolves URNs to canonical JSON + HTML artifacts.
12
+ #
13
+ # Sources:
14
+ # - schema manifest: schema and declared-element URNs
15
+ # - Glossarist concept documents: term URNs
16
+ # - register.yaml: the dataset URN
17
+ #
18
+ # Artifacts are written under the output directory by URN path, so every
19
+ # URN emitted by a register (and every entity in the corpus) has a static,
20
+ # dereferenceable document without a server.
21
+ class UrnResolver
22
+ class NotFoundError < Error; end
23
+
24
+ attr_reader :urn
25
+
26
+ # @param manifest_file [String] schema manifest path
27
+ # @param urn [String, nil] corpus URN base; required unless a register
28
+ # supplies one
29
+ # @param concepts_dir [String, nil] directory of Glossarist concept YAML
30
+ # @param register_file [String, nil] Glossarist register.yaml
31
+ def initialize(manifest_file:, urn: nil, concepts_dir: nil,
32
+ register_file: nil)
33
+ @manifest_file = File.expand_path(manifest_file)
34
+ @register_file = register_file && File.expand_path(register_file)
35
+ @concepts_dir = concepts_dir && File.expand_path(concepts_dir)
36
+ @urn = urn && Urn.new(urn)
37
+ load_register if @register_file
38
+ end
39
+
40
+ # @return [Hash{String=>Hash}] URN → artifact payload
41
+ def artifacts
42
+ @artifacts ||= schema_artifacts
43
+ .merge(concept_artifacts)
44
+ .merge(dataset_artifacts)
45
+ end
46
+
47
+ # @param urn [String]
48
+ # @return [Hash] artifact payload
49
+ # @raise [NotFoundError]
50
+ def resolve(urn)
51
+ artifacts[normalize(urn)] || raise(
52
+ NotFoundError, "urn does not resolve: #{urn}"
53
+ )
54
+ end
55
+
56
+ # Write JSON and HTML for every artifact.
57
+ def write(output_directory)
58
+ artifacts.each_key { |key| write_urn(key, output_directory) }
59
+ Utils.log "Wrote #{artifacts.length} URN artifacts to #{output_directory}"
60
+ end
61
+
62
+ # Write JSON and HTML for one URN.
63
+ def write_urn(urn, output_directory)
64
+ payload = resolve(urn)
65
+ json = canonical_json(payload)
66
+ write_file(output_directory, urn, "json", json)
67
+ write_file(output_directory, urn, "html", html_document(urn, json))
68
+ end
69
+
70
+ private
71
+
72
+ def normalize(value)
73
+ value.to_s.delete_suffix(":*")
74
+ end
75
+
76
+ def base_urn
77
+ @urn&.to_s || @register_urn
78
+ end
79
+
80
+ def compose(component, leaf)
81
+ "#{base_urn}:#{component}:#{leaf}"
82
+ end
83
+
84
+ def load_register
85
+ @register = Glossarist::DatasetRegister.from_file(@register_file)
86
+ @register_urn = @register.urn
87
+ @urn ||= Urn.new(@register_urn) if @register_urn
88
+ end
89
+
90
+ def schema_artifacts
91
+ return {} unless base_urn
92
+
93
+ artifacts = {}
94
+ schemas.each do |schema|
95
+ artifacts[compose(:tech, schema.id)] = schema_payload(schema)
96
+ schema.children.each do |element|
97
+ payload = element_payload(schema, element)
98
+ artifacts[compose(:tech, "#{schema.id}.#{element.id}")] = payload if
99
+ payload
100
+ end
101
+ end
102
+ artifacts
103
+ end
104
+
105
+ def schemas
106
+ @schemas ||= begin
107
+ manifest = Expressir::SchemaManifest.from_file(@manifest_file)
108
+ manifest.schemas.flat_map do |entry|
109
+ Expressir::Express::Parser.from_file(entry.path.to_s).schemas
110
+ end
111
+ end
112
+ end
113
+
114
+ def schema_payload(schema)
115
+ {
116
+ "urn" => compose(:tech, schema.id),
117
+ "kind" => "schema",
118
+ "id" => schema.id,
119
+ "source" => relative_source(schema),
120
+ "interface" => Array(schema.interfaces).filter_map do |interface|
121
+ interface.schema&.id
122
+ end,
123
+ "elements" => schema.children.filter_map do |element|
124
+ element.id if declared_element?(element)
125
+ end.sort,
126
+ }
127
+ end
128
+
129
+ def element_payload(schema, element)
130
+ case element
131
+ when Expressir::Model::Declarations::Entity
132
+ entity_payload(schema, element)
133
+ when Expressir::Model::Declarations::Type
134
+ type_payload(schema, element)
135
+ end
136
+ end
137
+
138
+ def entity_payload(schema, entity)
139
+ {
140
+ "urn" => compose(:tech, "#{schema.id}.#{entity.id}"),
141
+ "kind" => "entity",
142
+ "schema" => schema.id,
143
+ "id" => entity.id,
144
+ "abstract" => entity.abstract,
145
+ "source" => relative_source(schema),
146
+ "subtype_of" => Array(entity.subtype_of).filter_map(&:id),
147
+ "attributes" => Array(entity.attributes).map do |attribute|
148
+ attribute_payload(attribute)
149
+ end,
150
+ "where_rules" => Array(entity.where_rules).map(&:id),
151
+ }
152
+ end
153
+
154
+ def type_payload(schema, type)
155
+ {
156
+ "urn" => compose(:tech, "#{schema.id}.#{type.id}"),
157
+ "kind" => "type",
158
+ "schema" => schema.id,
159
+ "id" => type.id,
160
+ "source" => relative_source(schema),
161
+ "underlying" => type.underlying_type.class.name.split("::").last,
162
+ "where_rules" => Array(type.where_rules).map(&:id),
163
+ }
164
+ end
165
+
166
+ def attribute_payload(attribute)
167
+ {
168
+ "id" => attribute.id,
169
+ "kind" => attribute.kind,
170
+ "optional" => attribute.optional == true,
171
+ "type" => attribute.type&.source,
172
+ }
173
+ end
174
+
175
+ def declared_element?(element)
176
+ element.is_a?(Expressir::Model::Declarations::Entity) ||
177
+ element.is_a?(Expressir::Model::Declarations::Type)
178
+ end
179
+
180
+ def relative_source(schema)
181
+ return nil unless schema.file
182
+
183
+ Pathname.new(schema.file.to_s)
184
+ .relative_path_from(Pathname.new(File.dirname(@manifest_file)))
185
+ .to_s
186
+ rescue ArgumentError
187
+ schema.file.to_s
188
+ end
189
+
190
+ def concept_artifacts
191
+ return {} unless @concepts_dir && base_urn
192
+ return {} unless Dir.exist?(@concepts_dir)
193
+
194
+ Dir[File.join(@concepts_dir,
195
+ "*.yaml")].each_with_object({}) do |path, map|
196
+ document = Glossarist::V3::ConceptDocument.from_yamls(File.read(path))
197
+ concept = document.concept
198
+ next unless concept
199
+
200
+ key = compose(:term, concept.data.id)
201
+ map[key] = concept_payload(concept, document)
202
+ end
203
+ end
204
+
205
+ def concept_payload(concept, document)
206
+ {
207
+ "urn" => compose(:term, concept.data.id),
208
+ "kind" => "concept",
209
+ "id" => concept.data.id,
210
+ "uuid" => concept.uuid,
211
+ "domain" => concept.data.domains&.first&.concept_id,
212
+ "localizations" => document.localizations.map do |localized|
213
+ localization_payload(localized)
214
+ end,
215
+ }
216
+ end
217
+
218
+ def localization_payload(localized)
219
+ data = localized.data
220
+ {
221
+ "language" => data.language_code,
222
+ "terms" => Array(data.terms).map(&:designation),
223
+ "definitions" => Array(data.definition).map(&:content),
224
+ "notes" => Array(data.notes).map(&:content),
225
+ "sources" => Array(data.sources).map do |source|
226
+ source_payload(source)
227
+ end,
228
+ }
229
+ end
230
+
231
+ def source_payload(source)
232
+ {
233
+ "id" => source.id,
234
+ "type" => source.type,
235
+ "source" => source.origin&.ref&.source,
236
+ "version" => source.origin&.ref&.version,
237
+ "sourced_from" => Array(source.sourced_from).map do |citation|
238
+ citation.ref&.source
239
+ end,
240
+ }
241
+ end
242
+
243
+ def dataset_artifacts
244
+ return {} unless @register
245
+
246
+ {
247
+ normalize(@register.urn) => {
248
+ "urn" => @register.urn,
249
+ "kind" => "dataset",
250
+ "id" => @register.id,
251
+ "ref" => @register.ref,
252
+ "year" => @register.year,
253
+ "supersedes" => @register.supersedes,
254
+ "tags" => @register.tags,
255
+ "sections" => Array(@register.sections).map do |s|
256
+ section_payload(s)
257
+ end,
258
+ },
259
+ }
260
+ end
261
+
262
+ def section_payload(section)
263
+ {
264
+ "id" => section.id,
265
+ "name" => section.name("eng"),
266
+ "children" => Array(section.children).map { |s| section_payload(s) },
267
+ }
268
+ end
269
+
270
+ # Deterministic documents: sorted object keys, trailing newline.
271
+ def canonical_json(value)
272
+ "#{JSON.pretty_generate(canonicalize(value))}\n"
273
+ end
274
+
275
+ def canonicalize(value)
276
+ case value
277
+ when Hash
278
+ value.keys.sort.to_h { |key| [key, canonicalize(value[key])] }
279
+ when Array
280
+ value.map { |item| canonicalize(item) }
281
+ else
282
+ value
283
+ end
284
+ end
285
+
286
+ def write_file(output_directory, urn, extension, content)
287
+ path = File.join(output_directory, artifact_path(urn, extension))
288
+ FileUtils.mkdir_p(File.dirname(path))
289
+ File.write(path, content, encoding: "utf-8")
290
+ end
291
+
292
+ def artifact_path(urn, extension)
293
+ segments = normalize(urn).split(":").map do |segment|
294
+ segment.gsub(/[^A-Za-z0-9._-]/, "_")
295
+ end
296
+ "#{segments.join('/')}.#{extension}"
297
+ end
298
+
299
+ def html_document(urn, json)
300
+ title = escape_html(urn)
301
+ <<~HTML
302
+ <!doctype html>
303
+ <html lang="en">
304
+ <head><meta charset="utf-8"><title>#{title}</title></head>
305
+ <body>
306
+ <h1>#{title}</h1>
307
+ <pre>#{escape_html(json)}</pre>
308
+ </body>
309
+ </html>
310
+ HTML
311
+ end
312
+
313
+ def escape_html(value)
314
+ value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
315
+ .gsub('"', "&quot;")
316
+ end
317
+ end
318
+ end
data/lib/suma/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Suma
4
- VERSION = "0.5.7"
4
+ VERSION = "0.5.8"
5
5
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # A bounded worker pool for independent per-schema work.
5
+ #
6
+ # Document compilation is deliberately excluded: Metanorma rendering is not
7
+ # thread-safe and memory-hungry, so it stays sequential and composes with
8
+ # StagedCollectionBuilder's process isolation. Parsing and file export are
9
+ # independent per schema and safe to overlap.
10
+ class WorkerPool
11
+ attr_reader :size
12
+
13
+ def initialize(size: 1)
14
+ @size = Integer(size)
15
+ raise ArgumentError, "worker pool size must be at least 1" if @size < 1
16
+ end
17
+
18
+ # Map +items+ in order, running at most +size+ blocks concurrently.
19
+ #
20
+ # @return [Array] results in input order
21
+ def map(items, &block)
22
+ return items.map(&block) if size == 1 || items.length <= 1
23
+
24
+ queue = Queue.new
25
+ items.each_with_index { |item, index| queue << [item, index] }
26
+ results = Array.new(items.length)
27
+ errors = Queue.new
28
+ workers = Array.new([size, items.length].min) do
29
+ worker(queue, results, errors, block)
30
+ end
31
+ workers.each(&:join)
32
+ raise errors.pop unless errors.empty?
33
+
34
+ results
35
+ end
36
+
37
+ private
38
+
39
+ def worker(queue, results, errors, block)
40
+ Thread.new do
41
+ loop do
42
+ pair = next_item(queue)
43
+ break unless pair
44
+
45
+ item, index = pair
46
+ begin
47
+ results[index] = block.call(item)
48
+ rescue StandardError => e
49
+ errors << e
50
+ break
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ def next_item(queue)
57
+ queue.pop(true)
58
+ rescue ThreadError
59
+ nil
60
+ end
61
+ end
62
+ end
data/lib/suma.rb CHANGED
@@ -7,8 +7,17 @@ require "fileutils"
7
7
  module Suma
8
8
  autoload :VERSION, "suma/version"
9
9
 
10
+ autoload :SchemaProfile, "suma/schema_profile"
11
+ autoload :BuildPlan, "suma/build_plan"
10
12
  autoload :Processor, "suma/processor"
13
+ autoload :Provenance, "suma/provenance"
14
+ autoload :Publication, "suma/publication"
15
+ autoload :ChangeClassification, "suma/change_classification"
16
+ autoload :ChangeImpact, "suma/change_impact"
17
+ autoload :ChangeManifest, "suma/change_manifest"
18
+ autoload :ClosureValidator, "suma/closure_validator"
11
19
  autoload :CollectionConfig, "suma/collection_config"
20
+ autoload :CorpusGraph, "suma/corpus_graph"
12
21
  autoload :CollectionManifest, "suma/collection_manifest"
13
22
  autoload :EengineConverter, "suma/eengine_converter"
14
23
  autoload :ExpressReformatter, "suma/express_reformatter"
@@ -19,6 +28,7 @@ module Suma
19
28
  autoload :MappingDrift, "suma/mapping_drift"
20
29
  autoload :NoteProcessor, "suma/note_processor"
21
30
  autoload :NullCache, "suma/null_cache"
31
+ autoload :OwlProjection, "suma/owl_projection"
22
32
  autoload :RegisterManifestGenerator, "suma/register_manifest_generator"
23
33
  autoload :SchemaCache, "suma/schema_cache"
24
34
  autoload :SchemaCategory, "suma/schema_category"
@@ -38,7 +48,9 @@ module Suma
38
48
  autoload :TermExtractor, "suma/term_extractor"
39
49
  autoload :ThorExt, "suma/thor_ext"
40
50
  autoload :Urn, "suma/urn"
51
+ autoload :UrnResolver, "suma/urn_resolver"
41
52
  autoload :Utils, "suma/utils"
53
+ autoload :WorkerPool, "suma/worker_pool"
42
54
 
43
55
  autoload :Cli, "suma/cli"
44
56
  autoload :Eengine, "suma/eengine"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.7
4
+ version: 0.5.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: expressir
@@ -166,7 +166,9 @@ executables:
166
166
  extensions: []
167
167
  extra_rdoc_files: []
168
168
  files:
169
+ - ".github/workflows/nightly-benchmark.yml"
169
170
  - ".github/workflows/rake.yml"
171
+ - ".github/workflows/release-provenance.yml"
170
172
  - ".github/workflows/release.yml"
171
173
  - ".gitignore"
172
174
  - ".rspec"
@@ -177,10 +179,16 @@ files:
177
179
  - Gemfile
178
180
  - README.adoc
179
181
  - Rakefile
182
+ - bin/benchmark_corpus
180
183
  - bin/console
184
+ - bin/release_provenance
181
185
  - bin/setup
182
186
  - exe/suma
183
187
  - lib/suma.rb
188
+ - lib/suma/build_plan.rb
189
+ - lib/suma/change_classification.rb
190
+ - lib/suma/change_impact.rb
191
+ - lib/suma/change_manifest.rb
184
192
  - lib/suma/cli.rb
185
193
  - lib/suma/cli/build.rb
186
194
  - lib/suma/cli/check_svg_quality.rb
@@ -192,10 +200,13 @@ files:
192
200
  - lib/suma/cli/generate_register.rb
193
201
  - lib/suma/cli/generate_schemas.rb
194
202
  - lib/suma/cli/reformat.rb
203
+ - lib/suma/cli/resolve.rb
195
204
  - lib/suma/cli/validate.rb
196
205
  - lib/suma/cli/validate_links.rb
206
+ - lib/suma/closure_validator.rb
197
207
  - lib/suma/collection_config.rb
198
208
  - lib/suma/collection_manifest.rb
209
+ - lib/suma/corpus_graph.rb
199
210
  - lib/suma/eengine.rb
200
211
  - lib/suma/eengine/errors.rb
201
212
  - lib/suma/eengine/wrapper.rb
@@ -213,7 +224,10 @@ files:
213
224
  - lib/suma/mapping_drift.rb
214
225
  - lib/suma/note_processor.rb
215
226
  - lib/suma/null_cache.rb
227
+ - lib/suma/owl_projection.rb
216
228
  - lib/suma/processor.rb
229
+ - lib/suma/provenance.rb
230
+ - lib/suma/publication.rb
217
231
  - lib/suma/register_manifest_generator.rb
218
232
  - lib/suma/schema_cache.rb
219
233
  - lib/suma/schema_category.rb
@@ -225,6 +239,7 @@ files:
225
239
  - lib/suma/schema_index.rb
226
240
  - lib/suma/schema_manifest_generator.rb
227
241
  - lib/suma/schema_naming.rb
242
+ - lib/suma/schema_profile.rb
228
243
  - lib/suma/schema_template.rb
229
244
  - lib/suma/schema_template/document.rb
230
245
  - lib/suma/schema_template/plain.rb
@@ -242,8 +257,10 @@ files:
242
257
  - lib/suma/term_extractor.rb
243
258
  - lib/suma/thor_ext.rb
244
259
  - lib/suma/urn.rb
260
+ - lib/suma/urn_resolver.rb
245
261
  - lib/suma/utils.rb
246
262
  - lib/suma/version.rb
263
+ - lib/suma/worker_pool.rb
247
264
  - sig/suma.rbs
248
265
  - suma.gemspec
249
266
  homepage: https://github.com/metanorma/suma