suma 0.4.0 → 0.5.1

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.
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "metanorma"
4
+ require "yaml"
5
+ require "fileutils"
6
+
7
+ module Suma
8
+ # Memory-bounded, resumable staged collection build (metanorma/suma#94).
9
+ #
10
+ # A normal collection build renders every member document in one OS process, so
11
+ # peak memory accumulates across the whole collection and a large collection
12
+ # (e.g. the STEP SRL) OOMs. This builder instead:
13
+ #
14
+ # 1. compiles each member in its OWN fresh OS process, preserving that
15
+ # member's unresolved cross-document references as stubs and writing the
16
+ # stub-bearing Semantic XML + anchors to a durable content-addressed store
17
+ # (the metanorma engine's +preserve_unresolved:+ / +artifact_store_dir:+
18
+ # render options, metanorma/metanorma#576);
19
+ # 2. runs those per-member processes SEQUENTIALLY (never in parallel --
20
+ # parallelism reintroduces the cross-linking / sectionsplit races that are
21
+ # deliberately avoided); each process exits and the OS reclaims its memory
22
+ # before the next, so peak memory is bounded by a single member; then
23
+ # 3. REINFLATES: a final pass assembles a manifest of the stored stubs and
24
+ # resolves them into real cross-document links (+reinflate:+), producing
25
+ # the same output an all-present build would.
26
+ #
27
+ # The empirical case (metanorma/metanorma#576): on the real SRL a single-process
28
+ # build peaks ~4.1 GB and OOMs; each member compiled in isolation peaks ~1 GB,
29
+ # so process isolation is a ~4x peak reduction that converts the OOM into a
30
+ # bounded footprint.
31
+ #
32
+ # This is opt-in; +Suma::Processor+ uses the normal single-process build unless
33
+ # staging is requested.
34
+ class StagedCollectionBuilder
35
+ STORE_DIRNAME = ".metanorma-collection-cache"
36
+
37
+ # +collection_config_path+ is the emitted collection manifest (the same
38
+ # +collection-output.yaml+ the normal build renders). Member filerefs are
39
+ # relative to its directory, so all per-member manifests are written there.
40
+ def initialize(collection_config_path:, output_directory:, coverpage: nil,
41
+ store_dir: nil, formats: %i[xml html])
42
+ @config_path = collection_config_path
43
+ @config_dir = File.dirname(File.expand_path(collection_config_path))
44
+ @output_directory = output_directory
45
+ @coverpage = coverpage
46
+ @store_dir = store_dir || File.join(output_directory, STORE_DIRNAME)
47
+ @formats = formats
48
+ end
49
+
50
+ def build
51
+ FileUtils.mkdir_p(@output_directory)
52
+ FileUtils.mkdir_p(@store_dir)
53
+ members = collection_members
54
+ Utils.log "[staged] #{members.size} members; one process per member, " \
55
+ "sequential; store: #{@store_dir}"
56
+ members.each_with_index { |member, i| stage_member(member, i) }
57
+ reinflate(members)
58
+ end
59
+
60
+ private
61
+
62
+ def manifest
63
+ @manifest ||= YAML.load_file(@config_path)
64
+ end
65
+
66
+ # Every document member of the manifest tree, in order: any node carrying a
67
+ # file reference (+fileref+ or legacy +file+) that is not an attachment.
68
+ def collection_members
69
+ members = []
70
+ walk_members(manifest["manifest"]) { |member| members << member }
71
+ members
72
+ end
73
+
74
+ def walk_members(node, &block)
75
+ case node
76
+ when Array then node.each { |child| walk_members(child, &block) }
77
+ when Hash then walk_member_hash(node, &block)
78
+ end
79
+ end
80
+
81
+ def walk_member_hash(node, &block)
82
+ ref = node["fileref"] || node["file"]
83
+ ref && !truthy?(node["attachment"]) and
84
+ yield({ "fileref" => ref, "identifier" => node["identifier"],
85
+ "sectionsplit" => node["sectionsplit"] }.compact)
86
+ node.each_value { |value| walk_members(value, &block) }
87
+ end
88
+
89
+ # Stage one member in its own process: a single-member manifest rendered with
90
+ # preserve + store. Written beside the collection config so relative filerefs
91
+ # resolve; removed afterwards.
92
+ def stage_member(member, idx)
93
+ one_path = File.join(@config_dir, "._suma_staged_#{idx}.yml")
94
+ File.write(one_path, single_member_manifest(member).to_yaml)
95
+ out = File.join(@output_directory, "._staged_out_#{idx}")
96
+ run_render(
97
+ manifest_path: one_path,
98
+ opts: { format: [:xml], output_folder: out,
99
+ preserve_unresolved: true, artifact_store_dir: @store_dir,
100
+ compile: { install_fonts: false } },
101
+ )
102
+ ensure
103
+ FileUtils.rm_f(one_path)
104
+ FileUtils.rm_rf(File.join(@output_directory, "._staged_out_#{idx}"))
105
+ end
106
+
107
+ # Assemble a manifest of the stored stubs and reinflate them into the site.
108
+ def reinflate(members)
109
+ reinf_dir = File.join(@output_directory, "_staged_reinflate")
110
+ FileUtils.mkdir_p(reinf_dir)
111
+ docrefs = stored_docrefs(members, reinf_dir)
112
+ reinf_path = File.join(reinf_dir, "reinflate.yml")
113
+ File.write(reinf_path,
114
+ base_manifest.merge("manifest" => collection_manifest(docrefs)).to_yaml)
115
+ Utils.log "[staged] reinflating #{docrefs.size} stored members -> " \
116
+ "#{@output_directory}"
117
+ run_render(
118
+ manifest_path: reinf_path,
119
+ opts: { format: @formats, output_folder: @output_directory,
120
+ reinflate: true, coverpage: @coverpage,
121
+ compile: { install_fonts: false } }.compact,
122
+ )
123
+ end
124
+
125
+ # Copy each member's stored stub into +dir+ and return docref entries pointing
126
+ # at the copies, keyed by real docid (as the store keys them).
127
+ def stored_docrefs(members, dir)
128
+ members.map.with_index do |member, i|
129
+ stored = stored_semantic(member["identifier"]) or
130
+ raise "[staged] no stored artefact for #{member['identifier'].inspect}"
131
+ name = "member_#{i}.xml"
132
+ FileUtils.cp(stored, File.join(dir, name))
133
+ dr = { "fileref" => name, "identifier" => member["identifier"] }
134
+ dr["sectionsplit"] = true if truthy?(member["sectionsplit"])
135
+ dr
136
+ end
137
+ end
138
+
139
+ # A minimal single-member manifest reusing the collection's directives and
140
+ # bibdata, so the isolated render carries the right collection identity.
141
+ def single_member_manifest(member)
142
+ base_manifest.merge("manifest" => collection_manifest([member]))
143
+ end
144
+
145
+ def collection_manifest(docrefs)
146
+ { "level" => "collection", "title" => "Collection",
147
+ "manifest" => [{ "level" => "subcollection", "title" => "Members",
148
+ "docref" => docrefs }] }
149
+ end
150
+
151
+ def base_manifest
152
+ { "directives" => manifest["directives"] || ["documents-inline"],
153
+ "bibdata" => manifest["bibdata"] }.compact
154
+ end
155
+
156
+ def stored_semantic(identifier)
157
+ Dir[File.join(@store_dir, "#{slug(identifier)}.*.semantic.xml")]
158
+ .max_by { |f| File.mtime(f) }
159
+ end
160
+
161
+ # Mirrors the store's filename slug (metanorma ArtifactStore#slug).
162
+ def slug(identifier)
163
+ identifier.to_s.gsub(/[^A-Za-z0-9._-]+/, "-")
164
+ .gsub(/-{2,}/, "-").gsub(/\A-|-\z/, "")
165
+ end
166
+
167
+ # Render a manifest in a FRESH child process so its memory is reclaimed on
168
+ # exit. Under +bundle exec+, the child inherits the bundle. Raises on failure.
169
+ def run_render(manifest_path:, opts:)
170
+ script = <<~RUBY
171
+ require "metanorma"
172
+ opts = Marshal.load(STDIN.read)
173
+ Metanorma::Collection.parse(#{manifest_path.inspect}).render(opts)
174
+ RUBY
175
+ IO.popen([RbConfig.ruby, "-e", script], "wb") do |io|
176
+ io.write(Marshal.dump(opts))
177
+ io.close_write
178
+ end
179
+ return if $?.success?
180
+
181
+ raise "[staged] render subprocess failed for #{manifest_path}"
182
+ end
183
+
184
+ def truthy?(val)
185
+ [true, "true"].include?(val)
186
+ end
187
+ end
188
+ end
@@ -6,15 +6,6 @@ require "glossarist"
6
6
 
7
7
  module Suma
8
8
  class TermExtractor
9
- REDUNDANT_NOTE_REGEX =
10
- %r{
11
- ^An? # Starts with "A" or "An"
12
- \s.*?\sis\sa\stype\sof # Text followed by "is a type of"
13
- (\sa|\san)? # Optional " a" or " an"
14
- \s\{\{[^\}]*\}\} # Text in double curly braces
15
- \s*?\.?$ # Optional whitespace and period at the end
16
- }x
17
-
18
9
  def initialize(schema_manifest_file, output_path, urn:,
19
10
  language_code: "eng")
20
11
  @schema_manifest_file = File.expand_path(schema_manifest_file)
@@ -140,7 +131,11 @@ module Suma
140
131
  data.domain = schema_domain
141
132
  data.sources = [source_ref] if source_ref
142
133
 
143
- notes = get_entity_notes(entity, schema_domain, data.definition)
134
+ notes = NoteProcessor.call(
135
+ entity.remarks,
136
+ definitions: data.definition,
137
+ xref_to_mention: method(:xref_to_mention),
138
+ )
144
139
  data.notes = notes if notes && !notes.empty?
145
140
  data.examples = []
146
141
  end
@@ -166,6 +161,10 @@ module Suma
166
161
  "{{#{urn},#{display}}}"
167
162
  end
168
163
 
164
+ def xref_to_mention(full_ref, display)
165
+ urn_mention(express_entity_urn(full_ref), display)
166
+ end
167
+
169
168
  def get_section_ref(schema)
170
169
  return nil unless @urn
171
170
 
@@ -219,183 +218,6 @@ module Suma
219
218
  [Glossarist::V3::DetailedDefinition.new(content: definition)]
220
219
  end
221
220
 
222
- def get_entity_notes(entity, schema_domain, definitions)
223
- notes = []
224
-
225
- if entity.remarks && !entity.remarks.empty?
226
- trimmed_def = trim_definition(entity.remarks)
227
- if trimmed_def && !trimmed_def.empty?
228
- notes << Glossarist::V3::DetailedDefinition.new(
229
- content: convert_express_xref(trimmed_def, schema_domain),
230
- )
231
- end
232
- end
233
-
234
- notes = only_keep_first_sentence(notes)
235
- notes = remove_see_content(notes)
236
- notes = remove_redundant_note(notes)
237
- notes = remove_invalid_references(notes)
238
- compare_with_definitions(notes, definitions)
239
- end
240
-
241
- def only_keep_first_sentence(notes)
242
- notes.each do |note|
243
- if note&.content && should_preserve_complete_structure?(note.content)
244
- next
245
- end
246
-
247
- if note&.content
248
- new_content = note.content
249
- .split(".\n").first.strip
250
- .split(". ").first.strip
251
- note.content = new_content.end_with?(".") ? new_content : "#{new_content}."
252
- end
253
- end
254
- end
255
-
256
- def should_preserve_complete_structure?(content)
257
- return false if content.nil? || content.empty?
258
-
259
- lines = content.split("\n")
260
- first_paragraph = lines.first&.strip
261
-
262
- if first_paragraph&.end_with?(":") && lines.length > 1
263
- if first_paragraph.count(".").positive?
264
- return false
265
- end
266
-
267
- remaining_content = lines[1..].join("\n")
268
- return starts_with_list?(remaining_content.strip)
269
- end
270
-
271
- false
272
- end
273
-
274
- def compare_with_definitions(notes, definitions)
275
- if notes&.first&.content == definitions&.first&.content
276
- return []
277
- end
278
-
279
- notes
280
- end
281
-
282
- def remove_invalid_references(notes)
283
- notes.reject do |note|
284
- note.content.include?("image::") ||
285
- note.content.match?(/<<(.*?){1,999}>>/)
286
- end
287
- end
288
-
289
- def remove_redundant_note(notes)
290
- notes.reject do |note|
291
- note.content.match?(REDUNDANT_NOTE_REGEX) &&
292
- !note.content.include?("\n")
293
- end
294
- end
295
-
296
- def remove_see_content(notes)
297
- notes.each do |note|
298
- note.content = note.content.gsub(/\s+\(see(.*?){1,999}\)/, "")
299
- end
300
- end
301
-
302
- def starts_with_list?(content)
303
- return false if content.nil? || content.empty?
304
-
305
- content.match?(/^\s*[*\-+]\s+/) || content.match?(/^\s*\d+\.\s+/)
306
- end
307
-
308
- def trim_definition(definition)
309
- return nil if definition.nil? || definition.empty?
310
-
311
- definition_str = definition.is_a?(Array) ? definition.join("\n\n") : definition.to_s
312
-
313
- return nil if definition_str.empty?
314
-
315
- paragraphs = definition_str.split("\n\n")
316
- first_paragraph = paragraphs.first
317
-
318
- combined = if paragraphs.length == 1
319
- apply_first_sentence_logic(first_paragraph)
320
- elsif first_paragraph.end_with?(":") && paragraphs.length > 1 && starts_with_list?(paragraphs[1])
321
- complete_list = extract_complete_list(paragraphs, 1)
322
- "#{first_paragraph}\n\n#{complete_list}"
323
- else
324
- apply_first_sentence_logic(first_paragraph)
325
- end
326
-
327
- combined = "#{combined}\n"
328
- combined.gsub!(/\n\/\/.*?\n/, "\n")
329
- combined.strip!
330
-
331
- express_reference_to_mention(combined)
332
- end
333
-
334
- def apply_first_sentence_logic(paragraph)
335
- new_content = paragraph
336
- .split(".\n").first.strip
337
- .split(". ").first.strip
338
-
339
- new_content.end_with?(".") ? new_content : "#{new_content}."
340
- end
341
-
342
- def extract_complete_list(paragraphs, start_index)
343
- return paragraphs[start_index] if start_index >= paragraphs.length
344
-
345
- combined = paragraphs[start_index].dup
346
- current_index = start_index + 1
347
- in_continuation_block = combined.include?("--") && !combined.match?(/--.*--/m)
348
-
349
- while current_index < paragraphs.length
350
- next_para = paragraphs[current_index]
351
-
352
- if next_para.match?(/^--\s*$/) || next_para.end_with?("--")
353
- in_continuation_block = !in_continuation_block
354
- combined += "\n\n#{next_para}"
355
- current_index += 1
356
- next
357
- end
358
-
359
- if in_continuation_block
360
- combined += "\n\n#{next_para}"
361
- current_index += 1
362
- next
363
- end
364
-
365
- if starts_with_list?(next_para) || is_list_continuation?(next_para)
366
- combined += "\n\n#{next_para}"
367
- current_index += 1
368
- in_continuation_block = true if next_para.include?("--") && !next_para.match?(/--.*--/m)
369
- else
370
- break
371
- end
372
- end
373
-
374
- combined
375
- end
376
-
377
- def is_list_continuation?(content)
378
- return false if content.nil? || content.empty?
379
-
380
- content.match?(/^\+\s*$/) ||
381
- content.match?(/^--\s*$/) ||
382
- content.match?(/^\s{2,}/) ||
383
- content.start_with?("which", "where", "that")
384
- end
385
-
386
- def express_reference_to_mention(description)
387
- description
388
- .gsub(/<<express:([\w.]+)>>/) do |_match|
389
- full_ref = Regexp.last_match[1]
390
- entity_id = full_ref.split(".").last
391
- urn_mention(express_entity_urn(full_ref), entity_id)
392
- end.gsub(/<<express:([\w.]+),([\w. ][\w. ]*)>>/) do |_match|
393
- full_ref = Regexp.last_match[1]
394
- display = Regexp.last_match(2)
395
- urn_mention(express_entity_urn(full_ref), display)
396
- end
397
- end
398
-
399
221
  def entity_name_to_text(entity_id)
400
222
  entity_id.downcase.gsub("_", " ")
401
223
  end
@@ -425,13 +247,5 @@ module Suma
425
247
  "#{entity_name_to_text(entity.id)} #{entity_ref}"
426
248
  end
427
249
  end
428
-
429
- def convert_express_xref(content, _schema_domain)
430
- content.gsub(/<<express:([\w.]+),([\w. ][\w. ]*)>>/) do
431
- full_ref = Regexp.last_match(1)
432
- display = Regexp.last_match(2)
433
- urn_mention(express_entity_urn(full_ref), display)
434
- end
435
- end
436
250
  end
437
251
  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.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/suma.rb CHANGED
@@ -16,6 +16,7 @@ module Suma
16
16
  autoload :LinkValidation, "suma/link_validation"
17
17
  autoload :LinkValidator, "suma/link_validator"
18
18
  autoload :ManifestTraverser, "suma/manifest_traverser"
19
+ autoload :NoteProcessor, "suma/note_processor"
19
20
  autoload :RegisterManifestGenerator, "suma/register_manifest_generator"
20
21
  autoload :SchemaCategory, "suma/schema_category"
21
22
  autoload :SchemaCollection, "suma/schema_collection"
@@ -28,6 +29,7 @@ module Suma
28
29
  autoload :SchemaNaming, "suma/schema_naming"
29
30
  autoload :SchemaTemplate, "suma/schema_template"
30
31
  autoload :SiteConfig, "suma/site_config"
32
+ autoload :StagedCollectionBuilder, "suma/staged_collection_builder"
31
33
  autoload :SvgQuality, "suma/svg_quality"
32
34
  autoload :TermClassification, "suma/term_classification"
33
35
  autoload :TermExtractor, "suma/term_extractor"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-07-05 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: expressir
@@ -214,7 +213,9 @@ files:
214
213
  - lib/suma/jsdai/figure_xml.rb
215
214
  - lib/suma/link_validation.rb
216
215
  - lib/suma/link_validator.rb
216
+ - lib/suma/link_validator/step.rb
217
217
  - lib/suma/manifest_traverser.rb
218
+ - lib/suma/note_processor.rb
218
219
  - lib/suma/processor.rb
219
220
  - lib/suma/register_manifest_generator.rb
220
221
  - lib/suma/schema_category.rb
@@ -230,6 +231,7 @@ files:
230
231
  - lib/suma/schema_template/document.rb
231
232
  - lib/suma/schema_template/plain.rb
232
233
  - lib/suma/site_config.rb
234
+ - lib/suma/staged_collection_builder.rb
233
235
  - lib/suma/svg_quality.rb
234
236
  - lib/suma/svg_quality/batch_report.rb
235
237
  - lib/suma/svg_quality/formatters.rb
@@ -251,7 +253,6 @@ licenses:
251
253
  - BSD-2-Clause
252
254
  metadata:
253
255
  rubygems_mfa_required: 'true'
254
- post_install_message:
255
256
  rdoc_options: []
256
257
  require_paths:
257
258
  - lib
@@ -266,8 +267,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
266
267
  - !ruby/object:Gem::Version
267
268
  version: '0'
268
269
  requirements: []
269
- rubygems_version: 3.5.22
270
- signing_key:
270
+ rubygems_version: 4.0.10
271
271
  specification_version: 4
272
272
  summary: Utility for SUMA (STEP Unified Model-Based Standards Architecture)
273
273
  test_files: []