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,611 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rdf"
4
+ require "rdf/turtle"
5
+ require "expressir"
6
+ # Expressir::SchemaManifest references Pathname without requiring it
7
+ # (expressir upstream gap); load it before any manifest parsing.
8
+ require "pathname"
9
+
10
+ module Suma
11
+ # Projects an EXPRESS corpus into OWL (Turtle).
12
+ #
13
+ # Mapping:
14
+ # - entity → owl:Class (labelled, isDefinedBy its schema)
15
+ # - subtype_of → rdfs:subClassOf
16
+ # - SELECT type → owl:Class + owl:unionOf member classes
17
+ # - ENUMERATION type → owl:Class + owl:oneOf named individuals
18
+ # - explicit attribute → owl:ObjectProperty (entity/select domains) or
19
+ # owl:DatatypeProperty (simple domains),
20
+ # attached to its owner class through an
21
+ # owl:Restriction carrying cardinality
22
+ # - where rule → rdfs:comment on the owning class
23
+ #
24
+ # IRIs derive from the corpus URN scheme via {Suma::Urn}:
25
+ # +<base>:tech:<schema>.<element>+ for classes and
26
+ # +<base>:tech:<schema>.<entity>.<attribute>+ for properties — the same
27
+ # identity used by the terminology and register layers.
28
+ #
29
+ # Both a whole-corpus ontology and per-schema projections are available;
30
+ # each projection is cached, so one instance parses the manifest once.
31
+ class OwlProjection
32
+ OWL = RDF::Vocabulary.new("http://www.w3.org/2002/07/owl#")
33
+ RDFS = RDF::Vocabulary.new("http://www.w3.org/2000/01/rdf-schema#")
34
+ XSD = RDF::Vocabulary.new("http://www.w3.org/2001/XMLSchema#")
35
+
36
+ DATA_RANGES = {
37
+ Expressir::Model::DataTypes::Boolean => XSD.boolean,
38
+ Expressir::Model::DataTypes::Logical => XSD.boolean,
39
+ Expressir::Model::DataTypes::Integer => XSD.integer,
40
+ Expressir::Model::DataTypes::Number => XSD.double,
41
+ Expressir::Model::DataTypes::Real => XSD.double,
42
+ Expressir::Model::DataTypes::String => XSD.string,
43
+ Expressir::Model::DataTypes::Binary => XSD.hexBinary,
44
+ }.freeze
45
+
46
+ BUILTIN_RANGES = {
47
+ "BOOLEAN" => XSD.boolean,
48
+ "LOGICAL" => XSD.boolean,
49
+ "INTEGER" => XSD.integer,
50
+ "NUMBER" => XSD.double,
51
+ "REAL" => XSD.double,
52
+ "STRING" => XSD.string,
53
+ "BINARY" => XSD.hexBinary,
54
+ }.freeze
55
+
56
+ AGGREGATE_CLASSES = [
57
+ Expressir::Model::DataTypes::Array,
58
+ Expressir::Model::DataTypes::Bag,
59
+ Expressir::Model::DataTypes::List,
60
+ Expressir::Model::DataTypes::Set,
61
+ ].freeze
62
+
63
+ ATTRIBUTE_KINDS = %w[EXPLICIT DERIVED INVERSE].freeze
64
+
65
+ # @param manifest_file [String] schema manifest path
66
+ # @param urn [String] base URN before the :tech component, e.g.
67
+ # "urn:iso:std:iso:10303:-2:ed-2:en". A URN already ending in
68
+ # +:tech+ is accepted and normalised, so IRIs never double the
69
+ # component.
70
+ def initialize(manifest_file, urn:)
71
+ @manifest_file = File.expand_path(manifest_file)
72
+ @urn = Urn.new(strip_tech(urn))
73
+ @graphs = {}
74
+ @schemas = load_schemas
75
+ @elements = index_elements
76
+ end
77
+
78
+ # @return [Urn]
79
+ attr_reader :urn
80
+
81
+ # @return [Array<String>] schema ids in the manifest, sorted
82
+ def schema_ids
83
+ @schemas.keys.sort
84
+ end
85
+
86
+ # The projected ontology.
87
+ #
88
+ # @param schema_id [String, nil] restrict the projection to one schema
89
+ # @return [RDF::Graph]
90
+ def graph(schema_id: nil)
91
+ key = schema_id&.downcase
92
+ @graphs[key] ||= build_graph(key)
93
+ end
94
+
95
+ # @param schema_id [String, nil] restrict the projection to one schema
96
+ # @return [String] Turtle serialization
97
+ def to_turtle(schema_id: nil)
98
+ graph(schema_id: schema_id).dump(:turtle, prefixes: default_prefixes)
99
+ end
100
+
101
+ # Structural validation of the projection: every entity/type is
102
+ # materialized as a class, every restriction property is declared,
103
+ # every class has a defining schema, and the superclass relation is
104
+ # acyclic over the corpus.
105
+ #
106
+ # @param schema_id [String, nil] validate only one schema's projection
107
+ # @return [Array<String>] problems (empty = valid)
108
+ def validate(schema_id: nil)
109
+ projected = if schema_id
110
+ key = normalize(schema_id)
111
+ unless @schemas.key?(key)
112
+ raise ArgumentError,
113
+ "unknown schema: #{schema_id}"
114
+ end
115
+
116
+ { key => @schemas[key] }
117
+ else
118
+ @schemas
119
+ end
120
+ projected_graph = graph(schema_id: schema_id)
121
+ problems = []
122
+ problems.concat(validate_materialization(projected))
123
+ problems.concat(validate_restrictions(projected_graph))
124
+ problems.concat(validate_orphans(projected_graph))
125
+ problems.concat(validate_superclasses(projected_graph))
126
+ problems.concat(validate_acyclic_superclasses(projected_graph))
127
+ problems
128
+ end
129
+
130
+ # @param schema_id [String]
131
+ # @param element_id [String]
132
+ # @return [String] class IRI
133
+ def class_iri(schema_id, element_id)
134
+ element_iri(schema_id, element_id)
135
+ end
136
+
137
+ # @param schema_id [String]
138
+ # @param entity_id [String]
139
+ # @param attribute_id [String]
140
+ # @return [String] property IRI
141
+ def property_iri(schema_id, entity_id, attribute_id)
142
+ element_iri(schema_id, "#{entity_id}.#{attribute_id}")
143
+ end
144
+
145
+ private
146
+
147
+ def strip_tech(urn)
148
+ urn.to_s
149
+ .delete_suffix(Urn::WILDCARD_SUFFIX)
150
+ .delete_suffix(":#{Urn::TECH_COMPONENT}")
151
+ end
152
+
153
+ def normalize(id)
154
+ id&.downcase
155
+ end
156
+
157
+ def load_schemas
158
+ manifest = Expressir::SchemaManifest.from_file(@manifest_file)
159
+ manifest.schemas.each_with_object({}) do |entry, map|
160
+ repo = Expressir::Express::Parser.from_file(entry.path.to_s)
161
+ repo.schemas.each { |schema| map[normalize(schema.id)] = schema }
162
+ end
163
+ end
164
+
165
+ # [schema_key, element_id.downcase] → declaration, for entities and
166
+ # named types only (enumeration items are also top-level children in
167
+ # the Expressir model and must not shadow declarations).
168
+ def index_elements
169
+ @schemas.each_with_object({}) do |(schema_key, schema), map|
170
+ schema.children.each do |element|
171
+ next unless class_element?(element)
172
+
173
+ map[[schema_key, normalize(element.id)]] = element
174
+ end
175
+ end
176
+ end
177
+
178
+ def class_element?(element)
179
+ element.is_a?(Expressir::Model::Declarations::Entity) ||
180
+ element.is_a?(Expressir::Model::Declarations::Type)
181
+ end
182
+
183
+ def default_prefixes
184
+ {
185
+ owl: OWL.to_uri.to_s,
186
+ rdfs: RDFS.to_uri.to_s,
187
+ rdf: RDF.to_uri.to_s,
188
+ xsd: XSD.to_uri.to_s,
189
+ }
190
+ end
191
+
192
+ def build_graph(schema_key)
193
+ graph = RDF::Graph.new
194
+ add_ontology_header(graph, schema_key)
195
+ @schemas.each do |key, schema|
196
+ next if schema_key && key != schema_key
197
+
198
+ project_schema(graph, key, schema)
199
+ end
200
+ graph
201
+ end
202
+
203
+ # A corpus projection is one ontology importing every schema
204
+ # ontology; a per-schema projection declares itself and imports the
205
+ # schemas it interfaces to, mirroring USE FROM / REFERENCE.
206
+ def add_ontology_header(graph, schema_key)
207
+ ontology = if schema_key
208
+ @urn.for_schema(schema_key)
209
+ else
210
+ @urn.to_s
211
+ end
212
+ graph << [RDF::URI(ontology), RDF.type, OWL.Ontology]
213
+ if schema_key
214
+ graph << [RDF::URI(ontology), RDFS.label, schema_key]
215
+ interface_targets(@schemas[schema_key]).each do |target|
216
+ graph << [RDF::URI(ontology), OWL.imports,
217
+ RDF::URI(@urn.for_schema(target))]
218
+ end
219
+ else
220
+ @schemas.each_key do |key|
221
+ graph << [RDF::URI(ontology), OWL.imports,
222
+ RDF::URI(@urn.for_schema(key))]
223
+ end
224
+ end
225
+ end
226
+
227
+ def interface_targets(schema)
228
+ (schema.interfaces || []).filter_map { |i| i.schema&.id }.map do |id|
229
+ normalize(id)
230
+ end.uniq.sort
231
+ end
232
+
233
+ def project_schema(graph, schema_key, schema)
234
+ schema.children.each do |element|
235
+ case element
236
+ when Expressir::Model::Declarations::Entity
237
+ project_entity(graph, schema_key, element)
238
+ when Expressir::Model::Declarations::Type
239
+ project_type(graph, schema_key, element)
240
+ end
241
+ end
242
+ end
243
+
244
+ def project_entity(graph, schema_key, entity)
245
+ iri = element_iri(schema_key, entity.id)
246
+ add_class(graph, iri, entity.id, schema_key)
247
+ add_remarks(graph, iri, entity)
248
+ subtype_targets(entity).each do |target|
249
+ graph << [RDF::URI(iri), RDFS.subClassOf,
250
+ RDF::URI(class_ref(target, schema_key))]
251
+ end
252
+ entity.attributes.to_a.each do |attribute|
253
+ next unless ATTRIBUTE_KINDS.include?(attribute.kind.to_s.upcase)
254
+
255
+ project_attribute(graph, iri, schema_key, entity, attribute)
256
+ end
257
+ add_where_rules(graph, iri, entity)
258
+ end
259
+
260
+ def project_type(graph, schema_key, type)
261
+ iri = element_iri(schema_key, type.id)
262
+ underlying = type.underlying_type
263
+ if DATA_RANGES.key?(underlying.class)
264
+ project_datatype(graph, iri, type, schema_key, underlying)
265
+ return
266
+ end
267
+
268
+ add_class(graph, iri, type.id, schema_key)
269
+ add_remarks(graph, iri, type)
270
+ case underlying
271
+ when Expressir::Model::DataTypes::Select
272
+ project_select(graph, iri, schema_key, underlying)
273
+ when Expressir::Model::DataTypes::Enumeration
274
+ project_enumeration(graph, iri, schema_key, type)
275
+ end
276
+ add_where_rules(graph, iri, type)
277
+ end
278
+
279
+ # A defined type over a simple type is an rdfs:Datatype, not a class.
280
+ def project_datatype(graph, iri, type, schema_key, underlying)
281
+ graph << [RDF::URI(iri), RDF.type, RDFS.Datatype]
282
+ graph << [RDF::URI(iri), RDFS.label, type.id.to_s]
283
+ graph << [RDF::URI(iri), RDFS.isDefinedBy,
284
+ RDF::URI(@urn.for_schema(schema_key))]
285
+ graph << [RDF::URI(iri), RDFS.subClassOf, DATA_RANGES[underlying.class]]
286
+ add_remarks(graph, iri, type)
287
+ add_where_rules(graph, iri, type)
288
+ end
289
+
290
+ def add_class(graph, iri, name, schema_key)
291
+ graph << [RDF::URI(iri), RDF.type, OWL.Class]
292
+ graph << [RDF::URI(iri), RDFS.label, name.to_s]
293
+ graph << [RDF::URI(iri), RDFS.isDefinedBy,
294
+ RDF::URI(@urn.for_schema(schema_key))]
295
+ end
296
+
297
+ def add_remarks(graph, iri, element)
298
+ Array(element.remarks).each do |remark|
299
+ graph << [RDF::URI(iri), RDFS.comment, remark.to_s]
300
+ end
301
+ end
302
+
303
+ def add_where_rules(graph, iri, element)
304
+ (element.where_rules || []).each do |rule|
305
+ graph << [RDF::URI(iri), RDFS.comment,
306
+ (rule.source || "WHERE #{rule.id}").to_s.strip]
307
+ end
308
+ end
309
+
310
+ def project_select(graph, iri, schema_key, select)
311
+ members = (select.items || []).map do |item|
312
+ RDF::URI(class_ref(item.id, schema_key))
313
+ end
314
+ graph << [RDF::URI(iri), OWL.unionOf, list_head(iri, members)]
315
+ append_list(graph, iri, members)
316
+ end
317
+
318
+ def project_enumeration(graph, iri, schema_key, type)
319
+ individuals = (type.underlying_type.items || []).map do |item|
320
+ individual = individual_iri(schema_key, type.id, item.id)
321
+ graph << [RDF::URI(individual), RDF.type, OWL.NamedIndividual]
322
+ graph << [RDF::URI(individual), RDF.type, RDF::URI(iri)]
323
+ graph << [RDF::URI(individual), RDFS.label, item.id.to_s]
324
+ RDF::URI(individual)
325
+ end
326
+ graph << [RDF::URI(iri), OWL.oneOf, list_head(iri, individuals)]
327
+ append_list(graph, iri, individuals)
328
+ end
329
+
330
+ def project_attribute(graph, class_iri, schema_key, entity, attribute)
331
+ iri = property_iri(schema_key, entity.id, attribute.id)
332
+ target = attribute_target(attribute.type, schema_key)
333
+ kind = target[:datatype] ? OWL.DatatypeProperty : OWL.ObjectProperty
334
+ graph << [RDF::URI(iri), RDF.type, kind]
335
+ graph << [RDF::URI(iri), RDFS.label, attribute.id.to_s]
336
+ graph << [RDF::URI(iri), RDFS.domain, RDF::URI(class_iri)]
337
+ graph << [RDF::URI(iri), RDFS.range, target[:range]]
338
+ annotate_attribute(graph, iri, attribute)
339
+ add_restriction(graph, class_iri, iri, attribute, target)
340
+ end
341
+
342
+ # Derived and inverse attributes are unformalized constraints: keep
343
+ # their EXPRESS clause as a comment on the property.
344
+ def annotate_attribute(graph, iri, attribute)
345
+ prefix = case attribute.kind.to_s.upcase
346
+ when "DERIVED" then "DERIVE"
347
+ when "INVERSE" then "INVERSE"
348
+ end
349
+ return unless prefix
350
+
351
+ graph << [RDF::URI(iri), RDFS.comment,
352
+ "#{prefix} #{attribute.source || attribute.id}"]
353
+ end
354
+
355
+ def add_restriction(graph, class_iri, property_iri, attribute, target)
356
+ node = RDF::URI(restriction_iri(property_iri))
357
+ graph << [RDF::URI(class_iri), RDFS.subClassOf, node]
358
+ graph << [node, RDF.type, OWL.Restriction]
359
+ graph << [node, OWL.onProperty, RDF::URI(property_iri)]
360
+ add_cardinality(graph, node, attribute, target)
361
+ end
362
+
363
+ def restriction_iri(property_iri)
364
+ "#{property_iri}.restriction"
365
+ end
366
+
367
+ def add_cardinality(graph, node, attribute, target)
368
+ if target[:aggregate]
369
+ graph << [node, OWL.minCardinality,
370
+ count_literal(aggregate_bound(attribute.type, :bound1) || 0)]
371
+ max = aggregate_bound(attribute.type, :bound2)
372
+ graph << [node, OWL.maxCardinality, count_literal(max)] if max
373
+ elsif attribute.optional || attribute.kind.to_s.upcase == "INVERSE"
374
+ graph << [node, OWL.minCardinality, count_literal(0)]
375
+ else
376
+ graph << [node, OWL.cardinality, count_literal(1)]
377
+ end
378
+ end
379
+
380
+ def count_literal(value)
381
+ RDF::Literal.new(value, datatype: XSD.nonNegativeInteger)
382
+ end
383
+
384
+ # Aggregate bounds are integer literals; the upper bound may also be
385
+ # the unbounded marker "?" (a SimpleReference), which yields nil.
386
+ def aggregate_bound(type, method)
387
+ bound = type.public_send(method)
388
+ return nil unless bound.is_a?(Expressir::Model::Literals::Integer)
389
+
390
+ Integer(bound.value, exception: false)
391
+ end
392
+
393
+ # Resolve an attribute's declared type to { datatype:, range: }.
394
+ def attribute_target(type, schema_key)
395
+ case type
396
+ when Expressir::Model::References::SimpleReference
397
+ simple_reference_target(type.id, schema_key)
398
+ when *AGGREGATE_CLASSES
399
+ attribute_target(type.base_type, schema_key).merge(aggregate: true)
400
+ else
401
+ datatype_range(type)
402
+ end
403
+ end
404
+
405
+ def simple_reference_target(name, schema_key)
406
+ declaration = @elements[[schema_key, normalize(name)]]
407
+ if declaration
408
+ declaration_target(declaration, schema_key)
409
+ elsif (range = BUILTIN_RANGES[name.to_s.upcase])
410
+ { datatype: true, range: range }
411
+ else
412
+ { datatype: false, range: RDF::URI(class_ref(name, schema_key)) }
413
+ end
414
+ end
415
+
416
+ def declaration_target(declaration, schema_key)
417
+ if declaration.is_a?(Expressir::Model::Declarations::Entity)
418
+ { datatype: false,
419
+ range: RDF::URI(element_iri(schema_key, declaration.id)) }
420
+ else
421
+ underlying = declaration.underlying_type
422
+ { datatype: DATA_RANGES.key?(underlying.class),
423
+ range: RDF::URI(element_iri(schema_key, declaration.id)) }
424
+ end
425
+ end
426
+
427
+ def datatype_range(type)
428
+ { datatype: true, range: DATA_RANGES.fetch(type.class, XSD.string) }
429
+ end
430
+
431
+ def subtype_targets(entity)
432
+ Array(entity.subtype_of).filter_map do |ref|
433
+ ref.id if ref.is_a?(Expressir::Model::References::SimpleReference)
434
+ end
435
+ end
436
+
437
+ # Resolve a class name to its corpus IRI. Local declarations win;
438
+ # otherwise any corpus schema declaring the name is used (EXPRESS
439
+ # brings interfaced elements into scope unqualified).
440
+ def class_ref(name, schema_key)
441
+ local = @elements[[schema_key, normalize(name)]]
442
+ return element_iri(schema_key, local.id) if local
443
+
444
+ found = @elements.keys
445
+ .select { |(_, element_id)| element_id == normalize(name) }
446
+ .min
447
+ if found
448
+ element_iri(found.first, @elements[found].id)
449
+ else
450
+ @urn.for_entity(name)
451
+ end
452
+ end
453
+
454
+ def element_iri(schema_id, element_id)
455
+ @urn.for_entity("#{schema_id}.#{element_id}")
456
+ end
457
+
458
+ def individual_iri(schema_id, type_id, item_id)
459
+ @urn.for_entity("#{schema_id}.#{type_id}.#{item_id}")
460
+ end
461
+
462
+ def list_head(owner_iri, items)
463
+ return RDF.nil if items.empty?
464
+
465
+ RDF::URI("#{owner_iri}.members.0")
466
+ end
467
+
468
+ # Named cons cells keep the ontology free of blank nodes, so parallel
469
+ # projections are comparable and serialization is reproducible.
470
+ def append_list(graph, owner_iri, items)
471
+ items.each_with_index do |item, index|
472
+ cell = RDF::URI("#{owner_iri}.members.#{index}")
473
+ graph << [cell, RDF.first, item]
474
+ rest = if index == items.length - 1
475
+ RDF.nil
476
+ else
477
+ RDF::URI("#{owner_iri}.members.#{index + 1}")
478
+ end
479
+ graph << [cell, RDF.rest, rest]
480
+ end
481
+ end
482
+
483
+ def validate_materialization(projected)
484
+ projected.flat_map do |schema_key, schema|
485
+ schema.children.filter_map do |element|
486
+ next unless class_element?(element)
487
+
488
+ iri = element_iri(schema_key, element.id)
489
+ next if declaration?(graph(schema_id: schema_key),
490
+ [RDF::URI(iri), RDF.type, OWL.Class])
491
+ next if declaration?(graph(schema_id: schema_key),
492
+ [RDF::URI(iri), RDF.type, RDFS.Datatype])
493
+
494
+ "missing class or datatype for #{schema_key}.#{element.id}"
495
+ end
496
+ end
497
+ end
498
+
499
+ def validate_restrictions(projected_graph)
500
+ matching(projected_graph, [nil, OWL.onProperty, nil])
501
+ .filter_map do |statement|
502
+ property = statement.object
503
+ next if matching(projected_graph,
504
+ [property, RDF.type, OWL.ObjectProperty]).any?
505
+ next if matching(projected_graph,
506
+ [property, RDF.type, OWL.DatatypeProperty]).any?
507
+
508
+ "restriction property not declared: #{property}"
509
+ end
510
+ end
511
+
512
+ # Every class and datatype must name a schema ontology of the corpus
513
+ # through rdfs:isDefinedBy; anything else is an orphan.
514
+ def validate_orphans(projected_graph)
515
+ schema_ontologies = @schemas.keys.to_h do |key|
516
+ [@urn.for_schema(key), true]
517
+ end
518
+ declarations = matching(projected_graph, [nil, RDF.type, OWL.Class]) +
519
+ matching(projected_graph, [nil, RDF.type, RDFS.Datatype])
520
+
521
+ declarations.filter_map do |statement|
522
+ subject = statement.subject
523
+ definitions = matching(projected_graph,
524
+ [subject, RDFS.isDefinedBy, nil])
525
+ next "orphan class without defining schema: #{subject}" if
526
+ definitions.empty?
527
+
528
+ known = definitions.any? do |definition|
529
+ definition.object.is_a?(RDF::URI) &&
530
+ schema_ontologies.key?(definition.object.to_s)
531
+ end
532
+ "class defined by unknown schema: #{subject}" unless known
533
+ end
534
+ end
535
+
536
+ def validate_superclasses(projected_graph)
537
+ corpus_classes = {}
538
+ @elements.each_key do |schema_key, element_id|
539
+ corpus_classes[element_iri(schema_key, element_id)] = true
540
+ end
541
+
542
+ matching(projected_graph, [nil, RDFS.subClassOf, nil])
543
+ .filter_map do |statement|
544
+ superclass = statement.object
545
+ next unless superclass.is_a?(RDF::URI)
546
+ next if restriction_node?(projected_graph, superclass)
547
+ next if datatype_node?(projected_graph, statement.subject)
548
+ next if corpus_classes.key?(superclass.to_s)
549
+
550
+ "superclass without class declaration: #{superclass}"
551
+ end
552
+ end
553
+
554
+ # Restriction nodes are blank, so superclass edges between URI nodes
555
+ # are the declared EXPRESS subtype relation; EXPRESS forbids cycles,
556
+ # and a cycle here means the projection is unsound.
557
+ def validate_acyclic_superclasses(projected_graph)
558
+ edges = {}
559
+ matching(projected_graph, [nil, RDFS.subClassOf, nil]).each do |statement|
560
+ subject = statement.subject
561
+ object = statement.object
562
+ next unless subject.is_a?(RDF::URI) && object.is_a?(RDF::URI)
563
+ next if restriction_node?(projected_graph, object)
564
+
565
+ (edges[subject.to_s] ||= []) << object.to_s
566
+ end
567
+
568
+ cycles = []
569
+ visited = {}
570
+ edges.each_key do |start|
571
+ detect_cycle(start, edges, visited, cycles)
572
+ end
573
+ cycles.uniq.map { |node| "superclass cycle involving #{node}" }
574
+ end
575
+
576
+ # RDF::Queryable#query returns nil when a pattern has no matches, so
577
+ # collect through the block form instead of chaining on the result.
578
+ def matching(graph, pattern)
579
+ results = []
580
+ graph.query(pattern) { |statement| results << statement }
581
+ results
582
+ end
583
+
584
+ def restriction_node?(graph, term)
585
+ term.is_a?(RDF::URI) &&
586
+ matching(graph, [term, RDF.type, OWL.Restriction]).any?
587
+ end
588
+
589
+ # Datatype aliases are declared rdfs:subClassOf their XSD range, so
590
+ # their superclass is intentionally outside the corpus class set.
591
+ def datatype_node?(graph, term)
592
+ term.is_a?(RDF::URI) &&
593
+ matching(graph, [term, RDF.type, RDFS.Datatype]).any?
594
+ end
595
+
596
+ def declaration?(graph, pattern)
597
+ matching(graph, pattern).any?
598
+ end
599
+
600
+ def detect_cycle(node, edges, visited, cycles)
601
+ return if visited[node] == :done
602
+ return cycles << node if visited[node] == :visiting
603
+
604
+ visited[node] = :visiting
605
+ (edges[node] || []).each do |child|
606
+ detect_cycle(child, edges, visited, cycles)
607
+ end
608
+ visited[node] = :done
609
+ end
610
+ end
611
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "metanorma"
4
+ require "expressir"
4
5
 
5
6
  module Suma
6
7
  class Processor
@@ -11,7 +12,9 @@ module Suma
11
12
  :compile_flag
12
13
 
13
14
  def initialize(metanorma_yaml_path:, schemas_all_path:, compile: true,
14
- output_directory: "_site", staged: false)
15
+ output_directory: "_site", staged: false,
16
+ incremental: false, state_file: nil, jobs: 1,
17
+ provenance_file: nil, sign_method: nil, sign_key: nil)
15
18
  @metanorma_yaml_path = metanorma_yaml_path
16
19
  @schemas_all_path = schemas_all_path
17
20
  @compile_flag = compile
@@ -19,6 +22,12 @@ module Suma
19
22
  # Opt-in memory-bounded staged build (metanorma/suma#94); the default
20
23
  # single-process build below is unchanged when false.
21
24
  @staged = staged
25
+ @incremental = incremental
26
+ @state_file = state_file
27
+ @jobs = jobs
28
+ @provenance_file = provenance_file
29
+ @sign_method = sign_method
30
+ @sign_key = sign_key
22
31
  end
23
32
 
24
33
  def run
@@ -26,17 +35,51 @@ module Suma
26
35
 
27
36
  collection_config = export_schema_config
28
37
 
29
- return nil unless @compile_flag
38
+ if @compile_flag
39
+ Utils.log "Compiling schema collection..."
40
+ compile_schema(schemas_all_path, collection_config)
30
41
 
31
- Utils.log "Compiling schema collection..."
32
- compile_schema(schemas_all_path, collection_config)
42
+ Utils.log "Compiling complete collection..."
43
+ compile_collection(collection_config, output_directory)
44
+ end
33
45
 
34
- Utils.log "Compiling complete collection..."
35
- compile_collection(collection_config, output_directory)
46
+ write_provenance
47
+ nil
36
48
  end
37
49
 
38
50
  private
39
51
 
52
+ def write_provenance
53
+ return unless @provenance_file
54
+
55
+ Provenance.write(
56
+ @provenance_file,
57
+ inputs: provenance_inputs,
58
+ outputs: provenance_outputs,
59
+ )
60
+ if @sign_method
61
+ signature = Provenance.sign(
62
+ @provenance_file, method: @sign_method, key: @sign_key
63
+ )
64
+ Utils.log "Signed build provenance (#{@sign_method}): #{signature}"
65
+ end
66
+ Utils.log "Wrote build provenance: #{@provenance_file}"
67
+ end
68
+
69
+ def provenance_inputs
70
+ [@metanorma_yaml_path, @schemas_all_path] + schema_source_paths
71
+ end
72
+
73
+ def schema_source_paths
74
+ manifest = Expressir::SchemaManifest.from_file(@schemas_all_path)
75
+ manifest.schemas.map { |entry| entry.path.to_s }
76
+ end
77
+
78
+ def provenance_outputs
79
+ [COLLECTION_OUTPUT_PATH, "plain_schemas", "schema_docs",
80
+ @output_directory]
81
+ end
82
+
40
83
  def export_schema_config
41
84
  site_config = Suma::SiteConfig::Config.from_file(metanorma_yaml_path)
42
85
 
@@ -60,6 +103,12 @@ module Suma
60
103
  manifest: collection_config.manifest,
61
104
  output_path_docs: "schema_docs",
62
105
  output_path_schemas: "plain_schemas",
106
+ options: {
107
+ annotations: true,
108
+ incremental: @incremental,
109
+ state_file: @state_file,
110
+ jobs: @jobs,
111
+ },
63
112
  )
64
113
 
65
114
  col.compile