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.
- checksums.yaml +4 -4
- data/.github/workflows/nightly-benchmark.yml +25 -0
- data/.github/workflows/release-provenance.yml +38 -0
- data/.rubocop_todo.yml +84 -0
- data/CLAUDE.md +44 -0
- data/Rakefile +13 -1
- data/bin/benchmark_corpus +138 -0
- data/bin/release_provenance +40 -0
- data/lib/suma/build_plan.rb +71 -0
- data/lib/suma/change_classification.rb +37 -0
- data/lib/suma/change_impact.rb +61 -0
- data/lib/suma/change_manifest.rb +147 -0
- data/lib/suma/cli/build.rb +23 -0
- data/lib/suma/cli/compare.rb +49 -2
- data/lib/suma/cli/core.rb +83 -0
- data/lib/suma/cli/export.rb +140 -10
- data/lib/suma/cli/extract_terms.rb +11 -0
- data/lib/suma/cli/generate_register.rb +12 -0
- data/lib/suma/cli/resolve.rb +45 -0
- data/lib/suma/cli/validate.rb +90 -0
- data/lib/suma/cli.rb +1 -0
- data/lib/suma/closure_validator.rb +317 -0
- data/lib/suma/corpus_graph.rb +141 -0
- data/lib/suma/express_schema.rb +22 -28
- data/lib/suma/null_cache.rb +4 -0
- data/lib/suma/owl_projection.rb +611 -0
- data/lib/suma/processor.rb +55 -6
- data/lib/suma/provenance.rb +159 -0
- data/lib/suma/publication.rb +108 -0
- data/lib/suma/register_manifest_generator.rb +76 -8
- data/lib/suma/schema_cache.rb +72 -13
- data/lib/suma/schema_category.rb +31 -2
- data/lib/suma/schema_collection.rb +58 -5
- data/lib/suma/schema_exporter.rb +5 -5
- data/lib/suma/schema_naming.rb +29 -33
- data/lib/suma/schema_profile.rb +134 -0
- data/lib/suma/term_classification.rb +9 -2
- data/lib/suma/term_extractor.rb +76 -35
- data/lib/suma/urn_resolver.rb +318 -0
- data/lib/suma/version.rb +1 -1
- data/lib/suma/worker_pool.rb +62 -0
- data/lib/suma.rb +12 -0
- 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
|
data/lib/suma/processor.rb
CHANGED
|
@@ -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
|
-
|
|
38
|
+
if @compile_flag
|
|
39
|
+
Utils.log "Compiling schema collection..."
|
|
40
|
+
compile_schema(schemas_all_path, collection_config)
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
Utils.log "Compiling complete collection..."
|
|
43
|
+
compile_collection(collection_config, output_directory)
|
|
44
|
+
end
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|