expressir 2.4.36 → 2.4.38

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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Eval
5
+ # Population-scale rule engine (TODO.instance/02). Each rule
6
+ # resolves once per schema edition and applies to many instances.
7
+ # Entity WHERE rules scope over the entity and its subtypes
8
+ # (ISO 10303-11 9.2.1, applicability via P21::Typing); UNIQUE
9
+ # rules (9.2.2.1) compare value tuples across the whole
10
+ # population; global rules (9.4) evaluate once per rule with the
11
+ # FOR entities bound to their instance aggregates. Per 9.4 a rule
12
+ # is violated only when it evaluates to false.
13
+ class Population
14
+ Finding = Struct.new(:kind, :instance, :rule_id, :message,
15
+ keyword_init: true)
16
+
17
+ attr_reader :schema, :population, :typing
18
+
19
+ def self.check(schema, population, short_names: nil)
20
+ new(schema, population, short_names: short_names).check
21
+ end
22
+
23
+ def initialize(schema, population, short_names: nil)
24
+ @schema = schema
25
+ @population = population
26
+ @typing = P21::Typing.new(schema, short_names: short_names)
27
+ @by_entity = nil
28
+ @slots = {}
29
+ end
30
+
31
+ def check
32
+ where_findings + unique_findings + global_findings
33
+ end
34
+
35
+ # Instances conforming to the named entity, including subtypes.
36
+ def instances_for(entity)
37
+ by_entity[entity.id&.safe_downcase] || []
38
+ end
39
+
40
+ private
41
+
42
+ def by_entity
43
+ @by_entity ||= begin
44
+ index = Hash.new { |hash, key| hash[key] = [] }
45
+ @population.instances.each do |id, record|
46
+ @typing.of(record).each do |entity|
47
+ index[entity.id&.safe_downcase] << [id, record]
48
+ end
49
+ end
50
+ index
51
+ end
52
+ end
53
+
54
+ def where_findings
55
+ @schema.entities.flat_map do |entity|
56
+ rules = Array(entity.where_rules)
57
+ next [] if rules.empty?
58
+
59
+ instances_for(entity).flat_map do |(id, record)|
60
+ rules.filter_map do |rule|
61
+ where_finding(id, record, entity, rule)
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def where_finding(id, record, entity, rule)
68
+ env = Rules.environment_for(@population, @schema, id, record, entity)
69
+ value = Expression.evaluate(rule.expression, env)
70
+ return unless false?(value)
71
+
72
+ Finding.new(kind: :where, instance: id, rule_id: rule.id,
73
+ message: "#{id}: #{entity.id}.#{rule.id} (9.4)")
74
+ rescue Expressir::Eval::Error => e
75
+ Finding.new(kind: :where, instance: id, rule_id: rule.id,
76
+ message: "#{id}: #{entity.id}.#{rule.id}: #{e.message}")
77
+ end
78
+
79
+ def unique_findings
80
+ @schema.entities.flat_map do |entity|
81
+ Array(entity.unique_rules).flat_map do |rule|
82
+ unique_rule_findings(entity, rule)
83
+ end
84
+ end
85
+ end
86
+
87
+ # 9.2.2.1: the value tuple of the referenced attributes must be
88
+ # unique across all instances of the entity and its subtypes.
89
+ def unique_rule_findings(entity, rule)
90
+ seen = {}
91
+ instances_for(entity).filter_map do |(id, record)|
92
+ env = Rules.environment_for(@population, @schema, id, record,
93
+ entity)
94
+ instance = Values::Instance.new(id: id.delete_prefix("#"),
95
+ record: record)
96
+ key = unique_key(rule, entity, env, instance)
97
+ if key.nil?
98
+ next error_finding(:unique, id, rule.id,
99
+ "#{id}: UNIQUE '#{rule.id}' references an " \
100
+ "attribute outside '#{entity.id}' (9.2.2.1)")
101
+ end
102
+
103
+ first = seen[key]
104
+ seen[key] = id
105
+ next unless first
106
+
107
+ Finding.new(kind: :unique, instance: id, rule_id: rule.id,
108
+ message: "#{id}: UNIQUE '#{rule.id}' duplicates " \
109
+ "#{first} (9.2.2.1)")
110
+ end
111
+ end
112
+
113
+ def unique_key(rule, entity, env, instance)
114
+ Array(rule.attributes).map do |reference|
115
+ name = reference.respond_to?(:id) ? reference.id : reference.to_s
116
+ return nil unless slots_for(entity).key?(name.safe_downcase)
117
+
118
+ Expression.instance_attribute(instance, name, env).inspect
119
+ end
120
+ end
121
+
122
+ def slots_for(entity)
123
+ @slots[entity] ||= Environment.new(schema: @schema).slots_for(entity)
124
+ end
125
+
126
+ # 9.4 global rules: the FOR entities are bound, in the rule's
127
+ # scope, to the aggregates of all their instances.
128
+ def global_findings
129
+ Array(@schema.rules).flat_map do |rule|
130
+ env = global_environment(rule)
131
+ if env.nil?
132
+ next [error_finding(:global, nil, rule.id,
133
+ "global rule '#{rule.id}' names no entity " \
134
+ "(9.4)")]
135
+ end
136
+
137
+ Array(rule.where_rules).filter_map do |where|
138
+ value = Expression.evaluate(where.expression, env)
139
+ next unless false?(value)
140
+
141
+ Finding.new(kind: :global, instance: nil, rule_id: rule.id,
142
+ message: "global rule '#{rule.id}' (9.4)")
143
+ rescue Expressir::Eval::Error => e
144
+ error_finding(:global, nil, rule.id,
145
+ "global rule '#{rule.id}': #{e.message}")
146
+ end
147
+ end
148
+ end
149
+
150
+ def global_environment(rule)
151
+ env = Environment.new(schema: @schema, population: @population)
152
+ bound = 0
153
+ Array(rule.applies_to).each do |reference|
154
+ name = reference.respond_to?(:id) ? reference.id : nil
155
+ entity = name && @schema.entities.find do |candidate|
156
+ candidate.id&.safe_downcase == name.safe_downcase
157
+ end
158
+ return nil unless entity
159
+
160
+ env = env.define(
161
+ name.safe_downcase,
162
+ Values::Aggregate.of(
163
+ :list, instances_for(entity).map do |(id, record)|
164
+ Values::Instance.new(id: id.delete_prefix("#"), record: record)
165
+ end
166
+ ),
167
+ )
168
+ bound += 1
169
+ end
170
+ bound.positive? ? env : nil
171
+ end
172
+
173
+ def error_finding(kind, instance, rule_id, message)
174
+ Finding.new(kind: kind, instance: instance, rule_id: rule_id,
175
+ message: message)
176
+ end
177
+
178
+ def false?(value)
179
+ return true if value == Values::LOGICAL_FALSE
180
+ return value.value == Values::LOGICAL_FALSE if value.is_a?(Values::Logical)
181
+
182
+ false
183
+ end
184
+ end
185
+ end
186
+ end
@@ -18,7 +18,6 @@ module Expressir
18
18
 
19
19
  def check(population, schema)
20
20
  population.instances.flat_map do |id, record|
21
- entity_for(schema, record)
22
21
  entity = entity_for(schema, record)
23
22
  next [] unless entity
24
23
 
@@ -13,5 +13,6 @@ module Expressir
13
13
  autoload :Expression, "#{__dir__}/eval/expression"
14
14
  autoload :Interpreter, "#{__dir__}/eval/interpreter"
15
15
  autoload :Rules, "#{__dir__}/eval/rules"
16
+ autoload :Population, "#{__dir__}/eval/population"
16
17
  end
17
18
  end
@@ -0,0 +1,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Mapping
5
+ # ARM↔MIM instance mapping verification (TODO.instance/04): given
6
+ # a module's mapping tables (Mapping::Document) and two
7
+ # populations — an ARM-typed one and a MIM one — verify the MIM
8
+ # population is the image of the ARM population. Each application
9
+ # element is an instance predicate: every ARM instance of the
10
+ # element's entity must have a MIM image whose typing includes the
11
+ # mapped MIM entity, whose element refpath (or an alt_map route)
12
+ # resolves from that image, whose attribute-mapping refpaths
13
+ # terminate at values equal to the ARM attribute values, and whose
14
+ # inline element rules evaluate true (Eval WHERE semantics, self
15
+ # bound to the image).
16
+ #
17
+ # Reference values correspond across the two populations through
18
+ # the mapping itself: an ARM `#n` terminal matches the MIM image
19
+ # of ARM instance n under the element that maps its keyword.
20
+ #
21
+ # Instance-level path walking covers the mapping-corpus RefPath
22
+ # constructs: bare and attribute-qualified nodes, `->`/`<-`
23
+ # navigation, `<=`/`=>` restatements, the §5 `||supertype||` and
24
+ # `*tree*` markers, and `{entity.attr = literal}` constraints
25
+ # (with `!` negation). Rules fields that name global rules rather
26
+ # than inline expressions are the domain of Mapping.rule_issues;
27
+ # they are not re-checked per instance here. Complex (multi-role)
28
+ # records are outside v1 scope: a complex candidate fails its
29
+ # route rather than silently passing.
30
+ class InstanceVerification
31
+ Finding = Struct.new(:arm_instance, :entity, :message, keyword_init: true)
32
+
33
+ # Part 21 value normalization for equality: enumerations compare
34
+ # by name, typed parameters by keyword+value.
35
+ module ValueCompare
36
+ module_function
37
+
38
+ def comparable(value)
39
+ case value
40
+ when P21::Enumeration then value.name
41
+ when P21::TypedValue then [value.keyword, comparable(value.value)]
42
+ when Array then value.map { |item| comparable(item) }
43
+ else value
44
+ end
45
+ end
46
+ end
47
+
48
+ include ValueCompare
49
+
50
+ attr_reader :findings
51
+
52
+ def initialize(mapping:, arm_schema:, mim_schema:,
53
+ arm_population:, mim_population:)
54
+ @mapping = mapping
55
+ @arm_schema = arm_schema
56
+ @mim_schema = mim_schema
57
+ @arm_population = arm_population
58
+ @mim_population = mim_population
59
+ @arm_typing = P21::Typing.new(arm_schema)
60
+ @mim_typing = P21::Typing.new(mim_schema)
61
+ @arm_slots = Eval::Environment.new(schema: arm_schema)
62
+ @mim_slots = Eval::Environment.new(schema: mim_schema)
63
+ @findings = []
64
+ @image_cache = {}
65
+ @rule_cache = {}
66
+ end
67
+
68
+ def verify
69
+ @mapping.ae.to_a.each { |element| verify_element(element) }
70
+ @findings
71
+ end
72
+
73
+ private
74
+
75
+ def verify_element(element)
76
+ arm_entity = find_entity(@arm_schema, element.entity)
77
+ unless arm_entity
78
+ @findings << Finding.new(arm_instance: nil, entity: element.entity,
79
+ message: "ARM schema declares no entity " \
80
+ "'#{element.entity}'")
81
+ return
82
+ end
83
+
84
+ arm_instances(arm_entity).each do |(id, record)|
85
+ next if image_of(element, record, id, {}.compare_by_identity)
86
+
87
+ @findings << Finding.new(
88
+ arm_instance: id, entity: element.entity,
89
+ message: "no MIM image of #{element.entity} instance #{id} " \
90
+ "through the mapping"
91
+ )
92
+ end
93
+ end
94
+
95
+ def arm_instances(entity)
96
+ @arm_population.instances.filter_map do |id, record|
97
+ next unless typed?(@arm_typing, record, entity)
98
+
99
+ [id, record]
100
+ end
101
+ end
102
+
103
+ # The MIM image id of one ARM instance, or nil. Tries the
104
+ # element's primary route then each alt_map route; memoized so
105
+ # cross-population reference correspondence stays linear.
106
+ def image_of(element, arm_record, arm_id, seen)
107
+ key = [element.object_id, arm_id]
108
+ return @image_cache[key] if @image_cache.key?(key)
109
+ return nil if seen[arm_id]
110
+
111
+ seen[arm_id] = true
112
+ @image_cache[key] = routes(element).filter_map do |route|
113
+ mim_entity = find_entity(@mim_schema, route[:entity])
114
+ next unless mim_entity
115
+
116
+ mim_candidates(mim_entity).find do |(mim_id, mim_record)|
117
+ route_matches?(route, element, arm_record,
118
+ arm_entity_of(element), mim_id, mim_record,
119
+ mim_entity, seen)
120
+ end&.first
121
+ end.first
122
+ end
123
+
124
+ def routes(element)
125
+ [{ entity: Mapping.entity_item(element.aimelt),
126
+ path: element.refpath&.content }].concat(
127
+ element.alt_map.to_a.map do |alt|
128
+ { entity: Mapping.entity_item(alt.aimelt),
129
+ path: alt.refpath&.content }
130
+ end,
131
+ ).reject { |route| route[:entity].nil? || route[:entity].empty? }
132
+ end
133
+
134
+ def route_matches?(route, element, arm_record, arm_entity,
135
+ mim_id, mim_record, mim_entity, seen)
136
+ walk = Walker.new(@mim_population, @mim_typing, @mim_slots, @mim_schema)
137
+ walk.follow(route[:path], mim_id, mim_record, mim_entity) if route[:path]
138
+ element.aa.to_a.all? do |aa|
139
+ attribute_matches?(aa, arm_record, arm_entity,
140
+ mim_id, mim_record, mim_entity, seen)
141
+ end && rules_hold?(element, mim_entity, mim_id, mim_record)
142
+ rescue Walker::BrokenPath
143
+ false
144
+ end
145
+
146
+ def attribute_matches?(attr_mapping, arm_record, arm_entity,
147
+ mim_id, mim_record, mim_entity, seen)
148
+ arm_value = arm_attribute_value(arm_record, arm_entity, attr_mapping)
149
+ walk = Walker.new(@mim_population, @mim_typing, @mim_slots, @mim_schema)
150
+ walk.follow(attr_mapping.refpath&.content, mim_id, mim_record, mim_entity)
151
+ values_match?(walk.terminal, arm_value, seen)
152
+ rescue Walker::BrokenPath
153
+ false
154
+ end
155
+
156
+ def values_match?(mim_value, arm_value, seen)
157
+ comparable(mim_value) == comparable(arm_value) ||
158
+ reference_corresponds?(mim_value, arm_value, seen)
159
+ end
160
+
161
+ # Reference values correspond through the mapping: the ARM `#n`
162
+ # must map to exactly the MIM instance the path reached.
163
+ def reference_corresponds?(mim_value, arm_value, seen)
164
+ return false unless reference?(mim_value) && reference?(arm_value)
165
+
166
+ arm_record = @arm_population.instances[arm_value]
167
+ return false unless arm_record
168
+
169
+ element = @mapping.ae.to_a.find do |ae|
170
+ ae.entity&.safe_downcase == arm_record.keyword&.safe_downcase
171
+ end
172
+ return false unless element
173
+
174
+ image_of(element, arm_record, arm_value, seen) == mim_value
175
+ end
176
+
177
+ def reference?(value)
178
+ value.is_a?(String) && value.start_with?("#")
179
+ end
180
+
181
+ def arm_attribute_value(arm_record, arm_entity, attr_mapping)
182
+ name = attr_mapping.attribute.to_s.split(".").last
183
+ index = @arm_slots.slots_for(arm_entity)[name&.safe_downcase]
184
+ raise Walker::BrokenPath, "ARM entity has no attribute '#{name}'" unless index
185
+
186
+ arm_record.params[index]
187
+ end
188
+
189
+ # Inline expression rules are instance predicates; a rules field
190
+ # that is not an expression (a named global rule) is left to
191
+ # Mapping.rule_issues.
192
+ def rules_hold?(element, mim_entity, mim_id, mim_record)
193
+ content = element.rules&.content
194
+ return true unless content
195
+
196
+ expression = @rule_cache[content] ||= parse_rule_expression(content)
197
+ return true unless expression
198
+
199
+ env = Eval::Rules.environment_for(@mim_population, @mim_schema,
200
+ mim_id, mim_record, mim_entity)
201
+ !logical_false?(Eval::Expression.evaluate(expression, env))
202
+ rescue Eval::Error, Walker::BrokenPath
203
+ false
204
+ end
205
+
206
+ def parse_rule_expression(content)
207
+ parsed = Expressir::Express::Parser.from_exp(
208
+ "SCHEMA mapping_rule_wrap; ENTITY wrap; " \
209
+ "WHERE rule_expr : #{content}; END_ENTITY; END_SCHEMA;",
210
+ )
211
+ parsed.schemas.first.entities.first.where_rules.first.expression
212
+ rescue StandardError
213
+ nil
214
+ end
215
+
216
+ def logical_false?(result)
217
+ return [false, Eval::Values::LOGICAL_FALSE].include?(result) unless
218
+ result.is_a?(Eval::Values::Logical)
219
+
220
+ result.value == Eval::Values::LOGICAL_FALSE
221
+ end
222
+
223
+ def mim_candidates(entity)
224
+ @mim_population.instances.filter_map do |id, record|
225
+ next unless typed?(@mim_typing, record, entity)
226
+
227
+ [id, record]
228
+ end
229
+ end
230
+
231
+ def typed?(typing, record, entity)
232
+ typing.issues(record).empty? &&
233
+ typing.of(record)
234
+ .any? { |decl| decl.id&.safe_downcase == entity.id&.safe_downcase }
235
+ end
236
+
237
+ def arm_entity_of(element)
238
+ find_entity(@arm_schema, element.entity)
239
+ end
240
+
241
+ def find_entity(schema, name)
242
+ key = Mapping.entity_item(name).to_s.safe_downcase
243
+ Array(schema.entities).find { |entity| entity.id&.safe_downcase == key }
244
+ end
245
+
246
+ # Walks a reference path through actual population instances,
247
+ # starting at a candidate MIM image. Raises BrokenPath the
248
+ # moment the population contradicts the path.
249
+ class Walker
250
+ class BrokenPath < StandardError
251
+ end
252
+
253
+ include ValueCompare
254
+
255
+ attr_reader :terminal
256
+
257
+ def initialize(population, typing, slots, schema)
258
+ @population = population
259
+ @typing = typing
260
+ @slots = slots
261
+ @schema = schema
262
+ end
263
+
264
+ def follow(content, id, record, entity)
265
+ @id = id
266
+ @record = record
267
+ @entity = entity
268
+ @last_attr = nil
269
+ @terminal = nil
270
+ RefPath.parse(content).steps.each { |step| follow_step(step) } if content
271
+ self
272
+ end
273
+
274
+ private
275
+
276
+ def follow_step(step)
277
+ return if marker_step?(step)
278
+ return constraint(step) if step.operator == "{"
279
+ return move_to(step.name) if step.name && %w[<= => || = *].include?(step.operator)
280
+ return navigate(step) if step.operator == "->"
281
+ return inverse(step) if step.operator == "<-"
282
+ return step_over(step) if step.name
283
+
284
+ raise BrokenPath, "unsupported path step '#{step.operator}'"
285
+ end
286
+
287
+ def marker_step?(step)
288
+ step.name.nil? && step.attribute.nil? && step.link.nil? &&
289
+ step.operator != "{" && step.operator != "(" && step.operator != ")"
290
+ end
291
+
292
+ # {entity.attr = literal} against the instance the walk sits
293
+ # on; `!{` negates.
294
+ def constraint(step)
295
+ unless step.name.nil? ||
296
+ step.name&.safe_downcase == @entity&.id&.safe_downcase
297
+ raise BrokenPath,
298
+ "constraint on '#{step.name}' does not name the " \
299
+ "walk's entity"
300
+ end
301
+ return unless step.attribute
302
+
303
+ actual = read(step.attribute)
304
+ expected = step.literal.to_s
305
+ expected = expected[1..-2] if expected.start_with?("'")
306
+ holds = comparable(actual).to_s.casecmp?(expected)
307
+ holds = !holds if step.negated
308
+ return if holds
309
+
310
+ raise BrokenPath,
311
+ "constraint '#{step.name}.#{step.attribute} = " \
312
+ "#{step.literal}' does not hold"
313
+ end
314
+
315
+ # The instance must conform to the named declaration; the walk
316
+ # context moves there so later slots resolve against it.
317
+ def move_to(name)
318
+ target = find_entity(name)
319
+ raise BrokenPath, "no declaration '#{name}'" unless target
320
+
321
+ conforms = @typing.of(@record)
322
+ .any? { |decl| decl.id&.safe_downcase == target.id&.safe_downcase }
323
+ raise BrokenPath, "instance does not conform to '#{name}'" unless conforms
324
+
325
+ @entity = target
326
+ end
327
+
328
+ # A bare or attribute-qualified node: restatement plus, when
329
+ # qualified, the attribute read — the terminal value, and the
330
+ # attribute a following `->` navigates through.
331
+ def step_over(step)
332
+ move_to(step.name)
333
+ @last_attr = step.attribute
334
+ @terminal = read(step.attribute) if step.attribute
335
+ end
336
+
337
+ # `attr -> entity`: the recorded attribute must hold a
338
+ # reference to an instance of the named entity.
339
+ def navigate(step)
340
+ attribute = step.attribute || @last_attr
341
+ raise BrokenPath, "'->' without an attribute" unless attribute
342
+
343
+ value = read(attribute)
344
+ target = @population.instances[value.to_s]
345
+ raise BrokenPath, "'#{attribute}' does not hold a reference" unless target
346
+
347
+ @record = target
348
+ @id = value.to_s
349
+ move_to(step.name)
350
+ @last_attr = nil
351
+ end
352
+
353
+ # `<- entity.attr`: some instance of the named entity must
354
+ # reference the current one through the attribute; the walk
355
+ # moves there.
356
+ def inverse(step)
357
+ raise BrokenPath, "'<-' without an attribute" unless step.attribute
358
+
359
+ found = @population.instances.find do |_, candidate|
360
+ keyword = candidate.keyword&.safe_downcase
361
+ next false unless keyword == step.name&.safe_downcase
362
+
363
+ read_attribute(candidate, step.attribute) == @id
364
+ end
365
+ unless found
366
+ raise BrokenPath,
367
+ "no '#{step.name}' instance references '#{@id}' " \
368
+ "through '#{step.attribute}'"
369
+ end
370
+
371
+ @record = found[1]
372
+ @id = found[0]
373
+ move_to(step.name)
374
+ @last_attr = step.attribute
375
+ end
376
+
377
+ def read(attribute)
378
+ read_attribute(@record, attribute, entity: @entity)
379
+ end
380
+
381
+ def read_attribute(record, attribute, entity: nil)
382
+ target = entity || entity_for_record(record)
383
+ index = @slots.slots_for(target)[attribute&.safe_downcase]
384
+ unless index
385
+ raise BrokenPath,
386
+ "no attribute '#{attribute}' on '#{target&.id}'"
387
+ end
388
+ raise BrokenPath, "complex records are outside v1 scope" if
389
+ record.is_a?(P21::ComplexRecord)
390
+
391
+ record.params[index]
392
+ end
393
+
394
+ def entity_for_record(record)
395
+ find_entity(record.keyword)
396
+ end
397
+
398
+ def find_entity(name)
399
+ key = name&.safe_downcase
400
+ Array(@schema.entities).find { |entity| entity.id&.safe_downcase == key }
401
+ end
402
+ end
403
+ end
404
+ end
405
+ end