branchproof 0.2.0
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 +7 -0
- data/CHANGELOG.md +36 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +202 -0
- data/NOTICE +3 -0
- data/README.md +242 -0
- data/doc/Branchproof/Analyzer.md +26 -0
- data/doc/Branchproof/CLI.md +15 -0
- data/doc/Branchproof/Error.md +6 -0
- data/doc/Branchproof/Evidence.md +46 -0
- data/doc/Branchproof/Instrumenter.md +18 -0
- data/doc/Branchproof/Limits.md +21 -0
- data/doc/Branchproof/Loader.md +25 -0
- data/doc/Branchproof/Minimizer.md +15 -0
- data/doc/Branchproof/MinitestAdapter.md +28 -0
- data/doc/Branchproof/Project.md +23 -0
- data/doc/Branchproof/RailsSupport/Error.md +6 -0
- data/doc/Branchproof/RailsSupport.md +32 -0
- data/doc/Branchproof/Records.md +38 -0
- data/doc/Branchproof/Report.md +26 -0
- data/doc/Branchproof/Runtime.md +37 -0
- data/doc/Branchproof/Source.md +23 -0
- data/doc/Branchproof/Worker.md +45 -0
- data/doc/Branchproof.md +33 -0
- data/doc/CHANGELOG.md +36 -0
- data/doc/README.md +242 -0
- data/exe/mcdc +6 -0
- data/lib/branchproof/analyzer.rb +454 -0
- data/lib/branchproof/cli.rb +266 -0
- data/lib/branchproof/evidence.rb +484 -0
- data/lib/branchproof/instrumenter.rb +150 -0
- data/lib/branchproof/limits.rb +44 -0
- data/lib/branchproof/loader.rb +140 -0
- data/lib/branchproof/minimizer.rb +198 -0
- data/lib/branchproof/minitest_adapter.rb +245 -0
- data/lib/branchproof/project.rb +53 -0
- data/lib/branchproof/rails_support.rb +74 -0
- data/lib/branchproof/records.rb +76 -0
- data/lib/branchproof/report.rb +412 -0
- data/lib/branchproof/runtime.rb +171 -0
- data/lib/branchproof/source.rb +238 -0
- data/lib/branchproof/version.rb +5 -0
- data/lib/branchproof/worker.rb +145 -0
- data/lib/branchproof.rb +24 -0
- data/lib/mcdc.rb +3 -0
- data/llms.txt +33 -0
- data/sig/branchproof.rbs +116 -0
- metadata +132 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Performs the Boolean, occurrence-level masking analysis. It deliberately
|
|
7
|
+
# accepts plain records so the core does not depend on Minitest or Runtime.
|
|
8
|
+
class Analyzer
|
|
9
|
+
CRITERION = "masking_occurrence_v1"
|
|
10
|
+
|
|
11
|
+
def initialize(inventory:, evidence:, limits:)
|
|
12
|
+
@inventory = inventory || {}
|
|
13
|
+
@evidence = evidence || {}
|
|
14
|
+
@limits = limits || {}
|
|
15
|
+
@vectors = records(@evidence, :vectors)
|
|
16
|
+
@constraint_states = Hash.new(0)
|
|
17
|
+
@missing_cache = {}
|
|
18
|
+
@invalid_diagnostics = []
|
|
19
|
+
@analysis_invalid = false
|
|
20
|
+
@diagnostic_keys = Set.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
decisions = records(@inventory, :decisions).map { analyze_decision(_1) }
|
|
25
|
+
proven = decisions.sum { |decision| decision[:condition_results].count { |result| result[:status] == "PROVEN" } }
|
|
26
|
+
eligible = decisions.sum { |decision| decision[:unsupported] ? 0 : decision[:conditions].length }
|
|
27
|
+
{
|
|
28
|
+
criterion_version: CRITERION,
|
|
29
|
+
decisions: decisions,
|
|
30
|
+
proven_count: proven,
|
|
31
|
+
eligible_count: eligible,
|
|
32
|
+
completeness: completeness(decisions),
|
|
33
|
+
diagnostics: decisions.flat_map { |decision| decision[:diagnostics] } + @invalid_diagnostics
|
|
34
|
+
}.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pair?(decision_id:, condition_index:, left:, right:)
|
|
38
|
+
return false unless compatible_vectors?(left, right, decision_id)
|
|
39
|
+
return false unless observed?(left, condition_index) && observed?(right, condition_index)
|
|
40
|
+
return false if value(left, condition_index) == value(right, condition_index)
|
|
41
|
+
return false if outcome(left) == outcome(right)
|
|
42
|
+
|
|
43
|
+
masks = [left, right].map { effective_mask(_1, decision_id) }
|
|
44
|
+
masks.all? { |mask| mask&.anybits?(1 << condition_index) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def missing(decision_id:, condition_index:)
|
|
48
|
+
cache_key = [decision_id, condition_index]
|
|
49
|
+
return @missing_cache[cache_key] if @missing_cache.key?(cache_key)
|
|
50
|
+
|
|
51
|
+
decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id }
|
|
52
|
+
return @missing_cache[cache_key] = nil unless decision
|
|
53
|
+
|
|
54
|
+
support_status = id(decision, :support_status).to_s
|
|
55
|
+
return @missing_cache[cache_key] = nil unless support_status.empty? || support_status.upcase == "SUPPORTED"
|
|
56
|
+
|
|
57
|
+
relevant = @vectors.select { id(_1, :decision_id) == decision_id }
|
|
58
|
+
valid = relevant.filter_map { valid_vector(_1, decision) }
|
|
59
|
+
if valid.empty?
|
|
60
|
+
return @missing_cache[cache_key] = {
|
|
61
|
+
status: "NO_OBSERVATIONS", constraints: [], candidate_vectors: [],
|
|
62
|
+
existing_vector_id: nil,
|
|
63
|
+
feasibility_statement: "No completed structural observation exists."
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
return @missing_cache[cache_key] = nil if first_pair(valid, condition_index, decision_id)
|
|
67
|
+
|
|
68
|
+
existing = valid.find { |vector| effective_mask(vector, decision_id).anybits?(1 << condition_index) }
|
|
69
|
+
signs = existing ? [!value(existing, condition_index)] : [true, false]
|
|
70
|
+
candidates = signs.filter_map { counterpart(decision, condition_index, _1) }
|
|
71
|
+
candidates.select! do |candidate|
|
|
72
|
+
!existing || pair?(decision_id: decision_id, condition_index: condition_index, left: existing,
|
|
73
|
+
right: candidate[:vector])
|
|
74
|
+
end
|
|
75
|
+
limited = @constraint_states[id(decision, :id)] > constraint_limit
|
|
76
|
+
if limited
|
|
77
|
+
@analysis_invalid = true
|
|
78
|
+
add_diagnostic("limit_reached", "error", nil, id(decision, :id))
|
|
79
|
+
end
|
|
80
|
+
@missing_cache[cache_key] = {
|
|
81
|
+
status: if limited
|
|
82
|
+
"LIMIT_REACHED"
|
|
83
|
+
else
|
|
84
|
+
(candidates.empty? ? "INFEASIBLE_IN_MODEL" : "CANDIDATE")
|
|
85
|
+
end,
|
|
86
|
+
constraints: candidates.flat_map { _1[:constraints] }.uniq,
|
|
87
|
+
candidate_vectors: candidates.map { _1[:vector] },
|
|
88
|
+
existing_vector_id: existing && id(existing, :id),
|
|
89
|
+
feasibility_statement: feasibility_statement(candidates)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def analyze_decision(decision)
|
|
96
|
+
decision_id = id(decision, :id)
|
|
97
|
+
unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
|
|
98
|
+
return {
|
|
99
|
+
decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [], witness_buckets: {},
|
|
100
|
+
conditions: [], unsupported: true,
|
|
101
|
+
completeness: { observation: true, attribution: true, analysis: true },
|
|
102
|
+
diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
vectors = @vectors.filter_map { |vector| valid_vector(vector, decision) }
|
|
107
|
+
masks = vectors.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
|
|
108
|
+
buckets = {}
|
|
109
|
+
conditions(decision).each do |condition|
|
|
110
|
+
index = id(condition, :index)
|
|
111
|
+
# These keys are serialized enum labels, not Boolean values.
|
|
112
|
+
# rubocop:disable-next Lint/BooleanSymbol -- serialized enum labels
|
|
113
|
+
buckets[index] = { true: [], false: [] }
|
|
114
|
+
vectors.each do |vector|
|
|
115
|
+
mask = masks[id(vector, :id)]
|
|
116
|
+
next unless mask&.anybits?(1 << index)
|
|
117
|
+
|
|
118
|
+
# rubocop:disable-next Lint/BooleanSymbol -- serialized enum labels
|
|
119
|
+
buckets[index][value(vector, index) ? :true : :false] << id(vector, :id)
|
|
120
|
+
end
|
|
121
|
+
buckets[index].each_value(&:sort!)
|
|
122
|
+
end
|
|
123
|
+
results = conditions(decision).map do |condition|
|
|
124
|
+
index = id(condition, :index)
|
|
125
|
+
# rubocop:disable Lint/BooleanSymbol -- serialized enum labels
|
|
126
|
+
true_ids = buckets[index][:true]
|
|
127
|
+
false_ids = buckets[index][:false]
|
|
128
|
+
# rubocop:enable Lint/BooleanSymbol
|
|
129
|
+
pair = first_pair(vectors, index, decision_id)
|
|
130
|
+
{
|
|
131
|
+
condition_id: id(condition, :id),
|
|
132
|
+
status: pair ? "PROVEN" : "NOT_PROVEN",
|
|
133
|
+
canonical_pair: pair && [id(pair[0], :id), id(pair[1], :id)],
|
|
134
|
+
constraint_result: pair ? nil : missing(decision_id: decision_id, condition_index: index),
|
|
135
|
+
reason: if pair
|
|
136
|
+
nil
|
|
137
|
+
else
|
|
138
|
+
(true_ids.empty? || false_ids.empty? ? "missing_effective_sign" : "no_independent_pair")
|
|
139
|
+
end
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
{
|
|
143
|
+
decision_id: decision_id,
|
|
144
|
+
effective_masks_by_vector: masks,
|
|
145
|
+
condition_results: results,
|
|
146
|
+
witness_buckets: buckets,
|
|
147
|
+
edge_table: edge_table(decision[:tree]),
|
|
148
|
+
conditions: conditions(decision),
|
|
149
|
+
completeness: { observation: true, attribution: true, analysis: !@analysis_invalid },
|
|
150
|
+
diagnostics: []
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def valid_vector(vector, decision)
|
|
155
|
+
return nil unless id(vector, :decision_id) == id(decision, :id)
|
|
156
|
+
|
|
157
|
+
decision_source = id(decision, :source_id)
|
|
158
|
+
vector_source = id(vector, :source_id)
|
|
159
|
+
if decision_source && vector_source && decision_source != vector_source
|
|
160
|
+
@analysis_invalid = true
|
|
161
|
+
add_diagnostic("incompatible_evidence", "error", id(vector, :id), id(decision, :id))
|
|
162
|
+
add_diagnostic("invalid_vector", "error", id(vector, :id), id(decision, :id))
|
|
163
|
+
return nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
status = id(vector, :status)
|
|
167
|
+
if status && status.to_s != "completed"
|
|
168
|
+
@analysis_invalid = true
|
|
169
|
+
add_diagnostic("incomplete_vector", "error", id(vector, :id))
|
|
170
|
+
return nil
|
|
171
|
+
end
|
|
172
|
+
unless evidence_compatible?(vector)
|
|
173
|
+
@analysis_invalid = true
|
|
174
|
+
add_diagnostic("incompatible_evidence", "error", id(vector, :id))
|
|
175
|
+
return nil
|
|
176
|
+
end
|
|
177
|
+
unless outcome(vector).is_a?(true.class) || outcome(vector).is_a?(false.class)
|
|
178
|
+
@analysis_invalid = true
|
|
179
|
+
add_diagnostic("invalid_vector", "error", id(vector, :id))
|
|
180
|
+
return nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
effective_mask(vector, id(decision, :id), decision[:tree])
|
|
184
|
+
vector
|
|
185
|
+
rescue ArgumentError
|
|
186
|
+
@analysis_invalid = true
|
|
187
|
+
add_diagnostic("invalid_vector", "error", id(vector, :id))
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def effective_mask(vector, decision_id, tree = nil)
|
|
192
|
+
decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id } unless tree
|
|
193
|
+
tree ||= decision && decision[:tree]
|
|
194
|
+
raise ArgumentError, "unknown decision" unless tree
|
|
195
|
+
|
|
196
|
+
values = values_for(vector)
|
|
197
|
+
index = 0
|
|
198
|
+
result, mask, consumed = replay(tree, values, index)
|
|
199
|
+
unless consumed == values.compact.length && result == outcome(vector)
|
|
200
|
+
raise ArgumentError,
|
|
201
|
+
"invalid structural trace"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
mask
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def replay(node, values, offset)
|
|
208
|
+
type = id(node, :type).to_sym
|
|
209
|
+
if type == :atom
|
|
210
|
+
index = id(node, :index)
|
|
211
|
+
value = values[index]
|
|
212
|
+
raise ArgumentError, "missing atom" if value.nil?
|
|
213
|
+
|
|
214
|
+
return [value, 1 << index, offset + 1]
|
|
215
|
+
end
|
|
216
|
+
left_result, left_mask, consumed = replay(node.fetch(:left), values, offset)
|
|
217
|
+
return [left_result, left_mask, consumed] if (type == :and && !left_result) || (type == :or && left_result)
|
|
218
|
+
|
|
219
|
+
right_result, right_mask, consumed = replay(node.fetch(:right), values, consumed)
|
|
220
|
+
if (type == :and && !right_result) || (type == :or && right_result)
|
|
221
|
+
[right_result, right_mask, consumed]
|
|
222
|
+
else
|
|
223
|
+
[right_result, left_mask | right_mask, consumed]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def first_pair(vectors, index, decision_id)
|
|
228
|
+
observed_vectors = vectors.select do |vector|
|
|
229
|
+
observed?(vector, index) && effective_mask(vector, decision_id).anybits?(1 << index)
|
|
230
|
+
end
|
|
231
|
+
observed_vectors.sort_by! { |vector| id(vector, :id).to_s }
|
|
232
|
+
grouped = observed_vectors.group_by do |vector|
|
|
233
|
+
# rubocop:disable-next Lint/BooleanSymbol -- serialized enum labels
|
|
234
|
+
value(vector, index) ? :true : :false
|
|
235
|
+
end
|
|
236
|
+
# rubocop:disable Lint/BooleanSymbol -- serialized enum labels
|
|
237
|
+
false_by_outcome = grouped.fetch(:false, []).group_by { |vector| outcome(vector) }
|
|
238
|
+
true_by_outcome = grouped.fetch(:true, []).group_by { |vector| outcome(vector) }
|
|
239
|
+
# rubocop:enable Lint/BooleanSymbol
|
|
240
|
+
false_by_outcome.each do |left_outcome, left_vectors|
|
|
241
|
+
right_vectors = true_by_outcome[!left_outcome]
|
|
242
|
+
next if right_vectors.nil?
|
|
243
|
+
|
|
244
|
+
left = left_vectors.first
|
|
245
|
+
right = right_vectors.first
|
|
246
|
+
return [left, right] if pair?(decision_id: decision_id, condition_index: index, left: left, right: right)
|
|
247
|
+
end
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def compatible_vectors?(left, right, decision_id)
|
|
252
|
+
id(left, :decision_id) == decision_id && id(right, :decision_id) == decision_id &&
|
|
253
|
+
source_identity(left) == source_identity(right) && complete?(left) && complete?(right)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def counterpart(decision, target, target_value)
|
|
257
|
+
decision_id = id(decision, :id)
|
|
258
|
+
@constraint_states[decision_id] += 1
|
|
259
|
+
return nil if @constraint_states[decision_id] > constraint_limit
|
|
260
|
+
|
|
261
|
+
target_condition = conditions(decision).find { |condition| id(condition, :index) == target }
|
|
262
|
+
return nil if target_condition && !id(target_condition,
|
|
263
|
+
:literal_truth).nil? && id(target_condition, :literal_truth) != target_value
|
|
264
|
+
|
|
265
|
+
return nil unless contains?(decision[:tree], target)
|
|
266
|
+
|
|
267
|
+
constraints = [{ condition_index: target, value: target_value }]
|
|
268
|
+
@constraint_decision_conditions = conditions(decision)
|
|
269
|
+
@constraint_current_decision = decision_id
|
|
270
|
+
return nil unless impose_path(decision[:tree], target, target_value, constraints)
|
|
271
|
+
|
|
272
|
+
values = Array.new(conditions(decision).length, false)
|
|
273
|
+
constraints.each { |constraint| values[constraint[:condition_index]] = constraint[:value] }
|
|
274
|
+
values[target] = target_value
|
|
275
|
+
outcome, observed = evaluate_with_trace(decision[:tree], values)
|
|
276
|
+
sparse = Array.new(values.length)
|
|
277
|
+
observed.each { |index, value| sparse[index] = value }
|
|
278
|
+
vector = { decision_id: id(decision, :id), source_id: id(decision, :source_id), status: "completed",
|
|
279
|
+
values: sparse, outcome: outcome, id: "candidate-#{target}-#{target_value}" }
|
|
280
|
+
constraints.each do |constraint|
|
|
281
|
+
condition = conditions(decision).find { |item| id(item, :index) == constraint[:condition_index] }
|
|
282
|
+
constraint[:condition_id] = id(condition, :id)
|
|
283
|
+
constraint[:required] = constraint[:value]
|
|
284
|
+
end
|
|
285
|
+
{ constraints: constraints, vector: vector }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def feasibility_statement(candidates)
|
|
289
|
+
return "Boolean requirement; application-level feasibility unknown" unless candidates.empty?
|
|
290
|
+
|
|
291
|
+
"Boolean requirement is not satisfiable in the model; application-level feasibility unknown"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def constraint_limit
|
|
295
|
+
value = id(@limits, :constraint_search_states)
|
|
296
|
+
value&.to_i&.positive? ? value.to_i : 10_000
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def edge_table(node)
|
|
300
|
+
graph = build_graph(node)
|
|
301
|
+
graph[:edges]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# rubocop:disable Lint/BooleanSymbol -- serialized enum labels
|
|
305
|
+
def build_graph(node, true_destination = :true, false_destination = :false)
|
|
306
|
+
# rubocop:enable Lint/BooleanSymbol
|
|
307
|
+
if id(node, :type).to_sym == :atom
|
|
308
|
+
index = id(node, :index)
|
|
309
|
+
entry = "atom#{index}"
|
|
310
|
+
edges = [{ from: entry, truth: true, destination: true_destination, clear_mask: 0, index: index },
|
|
311
|
+
{ from: entry, truth: false, destination: false_destination, clear_mask: 0, index: index }]
|
|
312
|
+
return { entry: entry, true_exits: [edges[0]], false_exits: [edges[1]], all_bits: 1 << index, edges: edges }
|
|
313
|
+
end
|
|
314
|
+
type = id(node, :type).to_sym
|
|
315
|
+
right = build_graph(node[:right], true_destination, false_destination)
|
|
316
|
+
if type == :and
|
|
317
|
+
left = build_graph(node[:left], right[:entry], false_destination)
|
|
318
|
+
right[:false_exits].each { |edge| edge[:clear_mask] |= left[:all_bits] }
|
|
319
|
+
{ entry: left[:entry], true_exits: right[:true_exits], false_exits: left[:false_exits] + right[:false_exits],
|
|
320
|
+
all_bits: left[:all_bits] | right[:all_bits], edges: left[:edges] + right[:edges] }
|
|
321
|
+
else
|
|
322
|
+
left = build_graph(node[:left], true_destination, right[:entry])
|
|
323
|
+
right[:true_exits].each { |edge| edge[:clear_mask] |= left[:all_bits] }
|
|
324
|
+
{ entry: left[:entry], true_exits: left[:true_exits] + right[:true_exits], false_exits: right[:false_exits],
|
|
325
|
+
all_bits: left[:all_bits] | right[:all_bits], edges: left[:edges] + right[:edges] }
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def impose_path(node, target, target_value, constraints)
|
|
330
|
+
return true if id(node, :type).to_sym == :atom && id(node, :index) == target
|
|
331
|
+
|
|
332
|
+
left = node[:left]
|
|
333
|
+
right = node[:right]
|
|
334
|
+
if contains?(left, target)
|
|
335
|
+
neutral = id(node, :type).to_sym == :and
|
|
336
|
+
impose_subtree(right, neutral, constraints) && impose_path(left, target, target_value, constraints)
|
|
337
|
+
elsif contains?(right, target)
|
|
338
|
+
impose_subtree(left, id(node, :type).to_sym == :and, constraints) &&
|
|
339
|
+
impose_path(right, target, target_value, constraints)
|
|
340
|
+
else
|
|
341
|
+
false
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def impose_subtree(node, desired, constraints)
|
|
346
|
+
@constraint_states[@constraint_current_decision] += 1
|
|
347
|
+
return false if @constraint_states[@constraint_current_decision] > constraint_limit
|
|
348
|
+
|
|
349
|
+
if id(node, :type).to_sym == :atom
|
|
350
|
+
condition = @constraint_decision_conditions.find { |item| id(item, :index) == id(node, :index) }
|
|
351
|
+
return false if condition && !id(condition, :literal_truth).nil? && id(condition, :literal_truth) != desired
|
|
352
|
+
|
|
353
|
+
constraints << { condition_index: id(node, :index), value: desired }
|
|
354
|
+
true
|
|
355
|
+
elsif desired == (id(node, :type).to_sym == :and)
|
|
356
|
+
impose_subtree(node[:left], desired, constraints) && impose_subtree(node[:right], desired, constraints)
|
|
357
|
+
else
|
|
358
|
+
impose_subtree(node[:left], desired, constraints) || impose_subtree(node[:right], desired, constraints)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def evaluate(node, values)
|
|
363
|
+
return values[id(node, :index)] if id(node, :type).to_sym == :atom
|
|
364
|
+
|
|
365
|
+
left = evaluate(node[:left], values)
|
|
366
|
+
return left if id(node, :type).to_sym == :and && !left
|
|
367
|
+
return left if id(node, :type).to_sym == :or && left
|
|
368
|
+
|
|
369
|
+
evaluate(node[:right], values)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def evaluate_with_trace(node, values, trace = [])
|
|
373
|
+
if id(node, :type).to_sym == :atom
|
|
374
|
+
value = !values[id(node, :index)].nil? && values[id(node, :index)] != false
|
|
375
|
+
trace << [id(node, :index), value]
|
|
376
|
+
return [value, trace]
|
|
377
|
+
end
|
|
378
|
+
left, = evaluate_with_trace(node[:left], values, trace)
|
|
379
|
+
return [left, trace] if id(node, :type).to_sym == :and && !left
|
|
380
|
+
return [left, trace] if id(node, :type).to_sym == :or && left
|
|
381
|
+
|
|
382
|
+
evaluate_with_trace(node[:right], values, trace)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def contains?(node, target)
|
|
386
|
+
if id(node,
|
|
387
|
+
:type).to_sym == :atom
|
|
388
|
+
id(node,
|
|
389
|
+
:index) == target
|
|
390
|
+
else
|
|
391
|
+
contains?(node[:left],
|
|
392
|
+
target) || contains?(node[:right], target)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def values_for(vector)
|
|
397
|
+
raw = vector[:values] || vector["values"]
|
|
398
|
+
raise ArgumentError, "observation must contain booleans or nil" unless raw.is_a?(Array)
|
|
399
|
+
unless raw.all? { |value| value.nil? || value == true || value == false }
|
|
400
|
+
raise ArgumentError, "observation must contain booleans or nil"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
raw
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def outcome(vector) = vector[:outcome].nil? ? vector["outcome"] : vector[:outcome]
|
|
407
|
+
def value(vector, index) = values_for(vector)[index]
|
|
408
|
+
def observed?(vector, index) = !value(vector, index).nil?
|
|
409
|
+
|
|
410
|
+
def complete?(vector)
|
|
411
|
+
status = vector[:status] || vector["status"]
|
|
412
|
+
status.nil? || status.to_s == "completed"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def source_identity(vector) = vector[:source_id] || vector["source_id"]
|
|
416
|
+
|
|
417
|
+
def id(record, key) = record[key].nil? ? record[key.to_s] : record[key]
|
|
418
|
+
|
|
419
|
+
def records(record, key) = Array(id(record, key))
|
|
420
|
+
def conditions(decision) = records(decision, :conditions)
|
|
421
|
+
|
|
422
|
+
def evidence_compatible?(_vector)
|
|
423
|
+
schema = id(@evidence, :schema_version)
|
|
424
|
+
criterion = id(@evidence, :criterion_version)
|
|
425
|
+
(schema.nil? || schema.to_s.split(".").first == "1") &&
|
|
426
|
+
(criterion.nil? || criterion.to_s == CRITERION)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def diagnostic(code, severity, decision_id, vector_id)
|
|
430
|
+
{ code: code, severity: severity, message: code.to_s, source_id: nil,
|
|
431
|
+
decision_id: decision_id, execution_id: nil, test_id: nil,
|
|
432
|
+
details: { vector_id: vector_id } }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def add_diagnostic(code, severity, vector_id, decision_id = nil)
|
|
436
|
+
key = [code, vector_id, decision_id]
|
|
437
|
+
return if @diagnostic_keys.include?(key)
|
|
438
|
+
|
|
439
|
+
@diagnostic_keys << key
|
|
440
|
+
@invalid_diagnostics << diagnostic(code, severity, decision_id, vector_id)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def completeness(decisions)
|
|
444
|
+
evidence_completeness = id(@evidence, :completeness) || {}
|
|
445
|
+
nested_analysis = evidence_completeness.fetch(:analysis, evidence_completeness.fetch("analysis", true))
|
|
446
|
+
{ observation: evidence_completeness.fetch(:observation, evidence_completeness.fetch("observation", true)),
|
|
447
|
+
attribution: evidence_completeness.fetch(:attribution, evidence_completeness.fetch("attribution", true)),
|
|
448
|
+
analysis: !@analysis_invalid && nested_analysis && decisions.all? do |decision|
|
|
449
|
+
decision[:completeness][:analysis]
|
|
450
|
+
end &&
|
|
451
|
+
id(@evidence, :analysis) != false }
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|