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
@@ -0,0 +1,368 @@
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, plus an index of the set.
57
+ def write(output_directory)
58
+ artifacts.each_key { |key| write_urn(key, output_directory) }
59
+ FileUtils.mkdir_p(output_directory)
60
+ File.write(File.join(output_directory, "index.html"),
61
+ index_document, encoding: "utf-8")
62
+ Utils.log "Wrote #{artifacts.length} URN artifacts to #{output_directory}"
63
+ end
64
+
65
+ # Write JSON and HTML for one URN.
66
+ def write_urn(urn, output_directory)
67
+ payload = resolve(urn)
68
+ json = canonical_json(payload)
69
+ write_file(output_directory, urn, "json", json)
70
+ write_file(output_directory, urn, "html", html_document(urn, json))
71
+ end
72
+
73
+ private
74
+
75
+ def normalize(value)
76
+ value.to_s.delete_suffix(":*")
77
+ end
78
+
79
+ def base_urn
80
+ @urn&.to_s || @register_urn
81
+ end
82
+
83
+ def compose(component, leaf)
84
+ "#{base_urn}:#{component}:#{leaf}"
85
+ end
86
+
87
+ def load_register
88
+ @register = Glossarist::DatasetRegister.from_file(@register_file)
89
+ @register_urn = @register.urn
90
+ @urn ||= Urn.new(@register_urn) if @register_urn
91
+ end
92
+
93
+ def schema_artifacts
94
+ return {} unless base_urn
95
+
96
+ artifacts = {}
97
+ schemas.each do |schema|
98
+ artifacts[compose(:tech, schema.id)] = schema_payload(schema)
99
+ schema.children.each do |element|
100
+ payload = element_payload(schema, element)
101
+ artifacts[compose(:tech, "#{schema.id}.#{element.id}")] = payload if
102
+ payload
103
+ add_attribute_artifacts(artifacts, schema, element)
104
+ end
105
+ end
106
+ artifacts
107
+ end
108
+
109
+ # Attribute property IRIs appear in the OWL projection as
110
+ # <schema>.<entity>.<attribute>; make them dereferenceable too.
111
+ def add_attribute_artifacts(artifacts, schema, element)
112
+ return unless element.is_a?(Expressir::Model::Declarations::Entity)
113
+
114
+ element.attributes.each do |attribute|
115
+ key = compose(:tech,
116
+ "#{schema.id}.#{element.id}.#{attribute.id}")
117
+ artifacts[key] = attribute_artifact(schema, element, attribute)
118
+ end
119
+ end
120
+
121
+ def attribute_artifact(schema, entity, attribute)
122
+ {
123
+ "urn" => compose(:tech, "#{schema.id}.#{entity.id}.#{attribute.id}"),
124
+ "kind" => "attribute",
125
+ "schema" => schema.id,
126
+ "entity" => entity.id,
127
+ "id" => attribute.id,
128
+ "attribute_kind" => attribute.kind,
129
+ "optional" => attribute.optional == true,
130
+ "type" => attribute.type&.source,
131
+ "source" => relative_source(schema),
132
+ }
133
+ end
134
+
135
+ def schemas
136
+ @schemas ||= begin
137
+ manifest = Expressir::SchemaManifest.from_file(@manifest_file)
138
+ manifest.schemas.flat_map do |entry|
139
+ Expressir::Express::Parser.from_file(entry.path.to_s).schemas
140
+ end
141
+ end
142
+ end
143
+
144
+ def schema_payload(schema)
145
+ {
146
+ "urn" => compose(:tech, schema.id),
147
+ "kind" => "schema",
148
+ "id" => schema.id,
149
+ "source" => relative_source(schema),
150
+ "interface" => Array(schema.interfaces).filter_map do |interface|
151
+ interface.schema&.id
152
+ end,
153
+ "elements" => schema.children.filter_map do |element|
154
+ element.id if declared_element?(element)
155
+ end.sort,
156
+ }
157
+ end
158
+
159
+ def element_payload(schema, element)
160
+ case element
161
+ when Expressir::Model::Declarations::Entity
162
+ entity_payload(schema, element)
163
+ when Expressir::Model::Declarations::Type
164
+ type_payload(schema, element)
165
+ end
166
+ end
167
+
168
+ def entity_payload(schema, entity)
169
+ {
170
+ "urn" => compose(:tech, "#{schema.id}.#{entity.id}"),
171
+ "kind" => "entity",
172
+ "schema" => schema.id,
173
+ "id" => entity.id,
174
+ "abstract" => entity.abstract,
175
+ "source" => relative_source(schema),
176
+ "subtype_of" => Array(entity.subtype_of).filter_map(&:id),
177
+ "attributes" => Array(entity.attributes).map do |attribute|
178
+ attribute_payload(attribute)
179
+ end,
180
+ "where_rules" => Array(entity.where_rules).map(&:id),
181
+ }
182
+ end
183
+
184
+ def type_payload(schema, type)
185
+ {
186
+ "urn" => compose(:tech, "#{schema.id}.#{type.id}"),
187
+ "kind" => "type",
188
+ "schema" => schema.id,
189
+ "id" => type.id,
190
+ "source" => relative_source(schema),
191
+ "underlying" => type.underlying_type.class.name.split("::").last,
192
+ "where_rules" => Array(type.where_rules).map(&:id),
193
+ }
194
+ end
195
+
196
+ def attribute_payload(attribute)
197
+ {
198
+ "id" => attribute.id,
199
+ "kind" => attribute.kind,
200
+ "optional" => attribute.optional == true,
201
+ "type" => attribute.type&.source,
202
+ }
203
+ end
204
+
205
+ def declared_element?(element)
206
+ element.is_a?(Expressir::Model::Declarations::Entity) ||
207
+ element.is_a?(Expressir::Model::Declarations::Type)
208
+ end
209
+
210
+ def relative_source(schema)
211
+ return nil unless schema.file
212
+
213
+ Pathname.new(schema.file.to_s)
214
+ .relative_path_from(Pathname.new(File.dirname(@manifest_file)))
215
+ .to_s
216
+ rescue ArgumentError
217
+ schema.file.to_s
218
+ end
219
+
220
+ def concept_artifacts
221
+ return {} unless @concepts_dir && base_urn
222
+ return {} unless Dir.exist?(@concepts_dir)
223
+
224
+ Dir[File.join(@concepts_dir,
225
+ "*.yaml")].each_with_object({}) do |path, map|
226
+ document = Glossarist::V3::ConceptDocument.from_yamls(File.read(path))
227
+ concept = document.concept
228
+ next unless concept
229
+
230
+ key = compose(:term, concept.data.id)
231
+ map[key] = concept_payload(concept, document)
232
+ end
233
+ end
234
+
235
+ def concept_payload(concept, document)
236
+ {
237
+ "urn" => compose(:term, concept.data.id),
238
+ "kind" => "concept",
239
+ "id" => concept.data.id,
240
+ "uuid" => concept.uuid,
241
+ "domain" => concept.data.domains&.first&.concept_id,
242
+ "localizations" => document.localizations.map do |localized|
243
+ localization_payload(localized)
244
+ end,
245
+ }
246
+ end
247
+
248
+ def localization_payload(localized)
249
+ data = localized.data
250
+ {
251
+ "language" => data.language_code,
252
+ "terms" => Array(data.terms).map(&:designation),
253
+ "definitions" => Array(data.definition).map(&:content),
254
+ "notes" => Array(data.notes).map(&:content),
255
+ "sources" => Array(data.sources).map do |source|
256
+ source_payload(source)
257
+ end,
258
+ }
259
+ end
260
+
261
+ def source_payload(source)
262
+ {
263
+ "id" => source.id,
264
+ "type" => source.type,
265
+ "source" => source.origin&.ref&.source,
266
+ "version" => source.origin&.ref&.version,
267
+ "sourced_from" => Array(source.sourced_from).map do |citation|
268
+ citation.ref&.source
269
+ end,
270
+ }
271
+ end
272
+
273
+ def dataset_artifacts
274
+ return {} unless @register
275
+
276
+ {
277
+ normalize(@register.urn) => {
278
+ "urn" => @register.urn,
279
+ "kind" => "dataset",
280
+ "id" => @register.id,
281
+ "ref" => @register.ref,
282
+ "year" => @register.year,
283
+ "supersedes" => @register.supersedes,
284
+ "tags" => @register.tags,
285
+ "sections" => Array(@register.sections).map do |s|
286
+ section_payload(s)
287
+ end,
288
+ },
289
+ }
290
+ end
291
+
292
+ def section_payload(section)
293
+ {
294
+ "id" => section.id,
295
+ "name" => section.name("eng"),
296
+ "children" => Array(section.children).map { |s| section_payload(s) },
297
+ }
298
+ end
299
+
300
+ # Deterministic documents: sorted object keys, trailing newline.
301
+ def canonical_json(value)
302
+ "#{JSON.pretty_generate(canonicalize(value))}\n"
303
+ end
304
+
305
+ def canonicalize(value)
306
+ case value
307
+ when Hash
308
+ value.keys.sort.to_h { |key| [key, canonicalize(value[key])] }
309
+ when Array
310
+ value.map { |item| canonicalize(item) }
311
+ else
312
+ value
313
+ end
314
+ end
315
+
316
+ def write_file(output_directory, urn, extension, content)
317
+ path = File.join(output_directory, artifact_path(urn, extension))
318
+ FileUtils.mkdir_p(File.dirname(path))
319
+ File.write(path, content, encoding: "utf-8")
320
+ end
321
+
322
+ def artifact_path(urn, extension)
323
+ segments = normalize(urn).split(":").map do |segment|
324
+ segment.gsub(/[^A-Za-z0-9._-]/, "_")
325
+ end
326
+ "#{segments.join('/')}.#{extension}"
327
+ end
328
+
329
+ def html_document(urn, json)
330
+ title = escape_html(urn)
331
+ <<~HTML
332
+ <!doctype html>
333
+ <html lang="en">
334
+ <head><meta charset="utf-8"><title>#{title}</title></head>
335
+ <body>
336
+ <h1>#{title}</h1>
337
+ <pre>#{escape_html(json)}</pre>
338
+ </body>
339
+ </html>
340
+ HTML
341
+ end
342
+
343
+ # A deterministic index of every resolved URN, linked to its document.
344
+ def index_document
345
+ items = artifacts.keys.sort.map do |urn|
346
+ href = artifact_path(urn, "html")
347
+ %( <li><a href="#{escape_html(href)}">#{escape_html(urn)}</a></li>)
348
+ end.join("\n")
349
+ <<~HTML
350
+ <!doctype html>
351
+ <html lang="en">
352
+ <head><meta charset="utf-8"><title>Resolved URNs</title></head>
353
+ <body>
354
+ <h1>Resolved URNs</h1>
355
+ <ul>
356
+ #{items}
357
+ </ul>
358
+ </body>
359
+ </html>
360
+ HTML
361
+ end
362
+
363
+ def escape_html(value)
364
+ value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
365
+ .gsub('"', "&quot;")
366
+ end
367
+ end
368
+ 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.9"
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.9
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-21 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