branchproof 0.7.0 → 0.8.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +212 -11
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FocusedReport.md +5 -2
  13. data/doc/Branchproof/Loader.md +0 -4
  14. data/doc/Branchproof/MinitestAdapter.md +0 -3
  15. data/doc/Branchproof/Report.md +18 -0
  16. data/doc/Branchproof/Runtime.md +4 -0
  17. data/doc/Branchproof/SavedReport.md +15 -0
  18. data/doc/Branchproof.md +4 -1
  19. data/doc/CHANGELOG.md +35 -0
  20. data/doc/README.md +212 -11
  21. data/lib/branchproof/analyzer.rb +80 -44
  22. data/lib/branchproof/cli.rb +28 -19
  23. data/lib/branchproof/comparison.rb +207 -19
  24. data/lib/branchproof/comparison_report.rb +49 -1
  25. data/lib/branchproof/constraints.rb +363 -0
  26. data/lib/branchproof/coverage_index.rb +30 -1
  27. data/lib/branchproof/decision_syntax.rb +31 -15
  28. data/lib/branchproof/decision_table.rb +377 -0
  29. data/lib/branchproof/evidence.rb +51 -31
  30. data/lib/branchproof/flow_instrumentation.rb +17 -17
  31. data/lib/branchproof/focused_report.rb +77 -9
  32. data/lib/branchproof/instrumenter.rb +45 -34
  33. data/lib/branchproof/limits.rb +4 -1
  34. data/lib/branchproof/loader.rb +18 -6
  35. data/lib/branchproof/minimizer.rb +18 -13
  36. data/lib/branchproof/minitest_adapter.rb +11 -16
  37. data/lib/branchproof/records.rb +2 -0
  38. data/lib/branchproof/report.rb +301 -68
  39. data/lib/branchproof/runtime.rb +22 -30
  40. data/lib/branchproof/saved_report.rb +200 -14
  41. data/lib/branchproof/source.rb +90 -48
  42. data/lib/branchproof/version.rb +1 -1
  43. data/lib/branchproof/worker.rb +1 -4
  44. data/lib/branchproof.rb +2 -0
  45. data/llms.txt +4 -1
  46. data/sig/branchproof.rbs +47 -1
  47. metadata +6 -1
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+ require_relative "constraints"
5
+ require_relative "limits"
6
+
7
+ module Branchproof
8
+ # Derives the executable logical rules of a supported Boolean decision and
9
+ # overlays the runtime evidence that the existing Branchproof run captured.
10
+ #
11
+ # Rules come from the Boolean tree itself rather than from an exhaustive
12
+ # Cartesian expansion, so Ruby's short-circuit semantics are preserved: a
13
+ # condition the interpreter would skip becomes an explicit +dont_care+ value
14
+ # instead of two separate rules.
15
+ #
16
+ # Nothing here executes application code, and no additional test run is
17
+ # required: overlay consumes the vectors already recorded for the decision.
18
+ module DecisionTable
19
+ SCHEMA_VERSION = 1
20
+ CONSTRAINT_ANALYSIS_VERSION = Constraints::VERSION
21
+ TRUE_VALUE = "true"
22
+ FALSE_VALUE = "false"
23
+ DONT_CARE = "dont_care"
24
+ VALUE_LABELS = { TRUE_VALUE => "T", FALSE_VALUE => "F", DONT_CARE => "-" }.freeze
25
+ CONDITION_VALUES = [TRUE_VALUE, FALSE_VALUE, DONT_CARE].freeze
26
+ REACHABILITY_STATUSES = %w[observed unknown statically_impossible].freeze
27
+ COVERAGE_STATUSES = %w[covered missing excluded].freeze
28
+ TABLE_STATUSES = %w[calculated not_calculated].freeze
29
+ COVERAGE_SUMMARY_STATUSES = %w[covered partial unexecuted unsupported not_calculated].freeze
30
+ NOT_CALCULATED_REASONS = %w[
31
+ unsupported_decision decision_table_unavailable
32
+ decision_table_condition_limit_exceeded decision_table_rule_limit_exceeded
33
+ ].freeze
34
+ DEFAULT_MAX_CONDITIONS = Limits::DEFAULTS[:max_conditions_for_decision_table]
35
+ DEFAULT_MAX_RULES = Limits::DEFAULTS[:decision_table_rules_per_decision]
36
+
37
+ module_function
38
+
39
+ # Builds the reduced, runtime-overlaid table for one Boolean decision.
40
+ def build(decision:, vectors: [], limits: {}, reachability: true)
41
+ raise ArgumentError, "reachability must be a Boolean" unless [true, false].include?(reachability)
42
+
43
+ decision_id = fetch(decision, :id).to_s
44
+ reason = unavailable_reason(decision, limits)
45
+ return not_calculated(decision_id, reason) if reason
46
+
47
+ max_rules = limit(limits, :decision_table_rules_per_decision, DEFAULT_MAX_RULES)
48
+ paths = enumerate(fetch(decision, :tree), false, max_rules + 1)
49
+ return not_calculated(decision_id, "decision_table_rule_limit_exceeded") if paths.nil? || paths.length > max_rules
50
+
51
+ conditions = Array(fetch(decision, :conditions))
52
+ prepared_constraints = conditions.map do |condition|
53
+ raw = fetch(condition, :constraint)
54
+ raw = Constraints.symbolize(raw)
55
+ fetch(condition, :constraint_safe) == true && Constraints.usable?(raw) ? raw : nil
56
+ end
57
+ rules = paths.each_with_index.map do |path, index|
58
+ rule(decision_id, conditions, path, index, reachability: reachability,
59
+ prepared_constraints: prepared_constraints)
60
+ end
61
+ overlay(decision_id: decision_id, rules: rules, vectors: Array(vectors), reachability: reachability,
62
+ indexed: unique_atom_indices?(fetch(decision, :tree)))
63
+ end
64
+
65
+ # Names why a decision carries no Boolean table, or nil when it carries one.
66
+ def unavailable_reason(decision, limits)
67
+ support = fetch(decision, :support_status).to_s
68
+ return "unsupported_decision" unless support.empty? || support.casecmp("supported").zero?
69
+
70
+ tree = fetch(decision, :tree)
71
+ return "decision_table_unavailable" if tree.nil?
72
+
73
+ conditions = Array(fetch(decision, :conditions)).length
74
+ return "decision_table_condition_limit_exceeded" if conditions >
75
+ limit(limits, :max_conditions_for_decision_table,
76
+ DEFAULT_MAX_CONDITIONS)
77
+ return "decision_table_unavailable" unless representable?(tree, conditions)
78
+
79
+ nil
80
+ end
81
+
82
+ # Only AND, OR, NOT, and atoms within the decision's own condition range are
83
+ # representable; anything else leaves the table uncalculated rather than
84
+ # producing a table that does not describe the decision.
85
+ def representable?(node, condition_count)
86
+ case fetch(node, :type).to_s
87
+ when "atom" then fetch(node, :index).is_a?(Integer) && (0...condition_count).cover?(fetch(node, :index))
88
+ when "not" then representable?(fetch(node, :child), condition_count)
89
+ when "and", "or"
90
+ representable?(fetch(node, :left), condition_count) &&
91
+ representable?(fetch(node, :right), condition_count)
92
+ else false
93
+ end
94
+ end
95
+
96
+ def unique_atom_indices?(node, seen = {})
97
+ case fetch(node, :type).to_s
98
+ when "atom"
99
+ index = fetch(node, :index)
100
+ return false if seen.key?(index)
101
+
102
+ seen[index] = true
103
+ true
104
+ when "not"
105
+ unique_atom_indices?(fetch(node, :child), seen)
106
+ when "and", "or"
107
+ unique_atom_indices?(fetch(node, :left), seen) && unique_atom_indices?(fetch(node, :right), seen)
108
+ else false
109
+ end
110
+ end
111
+
112
+ # Enumerates every short-circuit evaluation path of the Boolean tree.
113
+ #
114
+ # +prefer+ orders the paths deterministically: a conjunction lists its
115
+ # short-circuiting false path first, a disjunction its true path first, and
116
+ # a negation inverts the preference it inherits. Returns nil once the path
117
+ # count would exceed +budget+.
118
+ def enumerate(node, prefer, budget)
119
+ type = fetch(node, :type).to_s
120
+ case type
121
+ when "atom"
122
+ index = fetch(node, :index).to_i
123
+ (prefer ? [true, false] : [false, true]).map { |value| { assignments: { index => value }, value: value } }
124
+ when "not"
125
+ paths = enumerate(fetch(node, :child), !prefer, budget)
126
+ paths&.map { |path| { assignments: path[:assignments], value: !path[:value] } }
127
+ when "and", "or"
128
+ combine(node, type, budget)
129
+ end
130
+ end
131
+
132
+ def combine(node, type, budget)
133
+ short_circuit = type != "and"
134
+ left = enumerate(fetch(node, :left), short_circuit, budget)
135
+ return nil unless left
136
+
137
+ right = nil
138
+ paths = []
139
+ left.each do |path|
140
+ if path[:value] == short_circuit
141
+ paths << path
142
+ next
143
+ end
144
+ right ||= enumerate(fetch(node, :right), short_circuit, budget)
145
+ return nil unless right
146
+
147
+ right.each do |tail|
148
+ paths << { assignments: path[:assignments].merge(tail[:assignments]), value: tail[:value] }
149
+ end
150
+ return nil if paths.length > budget
151
+ end
152
+ paths.length > budget ? nil : paths
153
+ end
154
+
155
+ # rubocop:disable-next Metrics/ParameterLists
156
+ def rule(decision_id, conditions, path, index, reachability: true, prepared_constraints: nil)
157
+ values = Array.new(conditions.length, DONT_CARE)
158
+ path[:assignments].each do |condition_index, value|
159
+ values[condition_index] = value ? TRUE_VALUE : FALSE_VALUE if condition_index < values.length
160
+ end
161
+ outcome = path[:value] ? true : false
162
+ reachability_state, reason = if reachability
163
+ static_reachability(conditions, values, prepared_constraints: prepared_constraints)
164
+ else
165
+ ["unknown", nil]
166
+ end
167
+ { id: rule_id(decision_id, values, outcome), label: "R#{index + 1}", index: index,
168
+ conditions: values, outcome: outcome, reachability: reachability_state, reachability_reason: reason }
169
+ end
170
+
171
+ # Rule identity depends only on the decision, the normalized condition
172
+ # vector, the expected outcome, and the table schema version. It never
173
+ # depends on a test name, a runtime observation, or the Minitest seed.
174
+ def rule_id(decision_id, values, outcome)
175
+ Records.id(schema_version: SCHEMA_VERSION, decision_id: decision_id.to_s,
176
+ conditions: values, outcome: outcome)
177
+ end
178
+
179
+ # Conservative static reachability: prove impossibility, or answer unknown.
180
+ def static_reachability(conditions, values, prepared_constraints: nil)
181
+ solver = Constraints::Solver.new
182
+ values.each_with_index do |value, index|
183
+ next if value == DONT_CARE
184
+
185
+ condition = conditions[index] || {}
186
+ truth = value == TRUE_VALUE
187
+ literal = fetch(condition, :literal_truth)
188
+ return %w[statically_impossible boolean_literal_conflict] if !literal.nil? && literal != truth
189
+
190
+ next unless fetch(condition, :constraint_safe) == true
191
+
192
+ prepared = prepared_constraints && prepared_constraints[index]
193
+ reason = if prepared
194
+ solver.add_prepared(prepared, truth)
195
+ else
196
+ solver.add(fetch(condition, :constraint), truth)
197
+ end
198
+ return ["statically_impossible", reason] if reason
199
+ end
200
+ ["unknown", nil]
201
+ end
202
+
203
+ def overlay(decision_id:, rules:, vectors:, reachability: true, indexed: false)
204
+ diagnostics = []
205
+ matches = indexed ? classify_vectors(rules, vectors) : generic_matches(rules, vectors)
206
+ overlaid = rules.each_with_index.map do |item, index|
207
+ overlay_rule_matches(decision_id, item, matches[index], diagnostics)
208
+ end
209
+ summary(decision_id: decision_id, rules: overlaid, diagnostics: diagnostics,
210
+ reachability_analyzed: reachability)
211
+ end
212
+
213
+ # Generated traces have nil in every skipped condition position. Their
214
+ # normalized vector is therefore an exact table key and can be classified
215
+ # without scanning every rule. Malformed or hand-built observations retain
216
+ # the historical matcher as a bounded compatibility fallback.
217
+ def classify_vectors(rules, vectors)
218
+ condition_count = rules.first ? Array(rules.first[:conditions]).length : 0
219
+ by_signature = {}
220
+ rules.each_with_index do |item, position|
221
+ (by_signature[signature(item[:conditions], item[:outcome])] ||= []) << position
222
+ end
223
+ matches = Array.new(rules.length) { [] }
224
+ vectors.each do |vector|
225
+ positions = fast_match_positions(vector, by_signature, condition_count)
226
+ if positions&.length == 1
227
+ matches[positions.first] << vector
228
+ else
229
+ rules.each_with_index do |item, position|
230
+ matches[position] << vector if matches?(item, vector)
231
+ end
232
+ end
233
+ end
234
+ matches
235
+ end
236
+
237
+ def generic_matches(rules, vectors)
238
+ matches = Array.new(rules.length) { [] }
239
+ rules.each_with_index do |rule, position|
240
+ vectors.each { |vector| matches[position] << vector if matches?(rule, vector) }
241
+ end
242
+ matches
243
+ end
244
+
245
+ def signature(values, outcome)
246
+ [outcome ? true : false, Array(values).map { |value| value == DONT_CARE ? nil : value == TRUE_VALUE }]
247
+ end
248
+
249
+ def fast_match_positions(vector, by_signature, condition_count)
250
+ values = Array(fetch(vector, :values))
251
+ valid_values = values.all? { |value| value.nil? || value == true || value == false }
252
+ return nil unless values.length >= condition_count && valid_values
253
+
254
+ normalized = values.first(condition_count).map do |value|
255
+ if value.nil?
256
+ DONT_CARE
257
+ elsif value
258
+ TRUE_VALUE
259
+ else
260
+ FALSE_VALUE
261
+ end
262
+ end
263
+ by_signature[signature(normalized, fetch(vector, :outcome))]
264
+ end
265
+
266
+ # Public compatibility wrapper: callers historically passed all vectors,
267
+ # so retain matcher filtering for direct calls.
268
+ def overlay_rule(decision_id, rule, vectors, diagnostics)
269
+ matched = Array(vectors).select { |vector| matches?(rule, vector) }
270
+ overlay_rule_matches(decision_id, rule, matched, diagnostics)
271
+ end
272
+
273
+ def overlay_rule_matches(decision_id, rule, matched, diagnostics)
274
+ observed = !matched.empty?
275
+ impossible = rule[:reachability] == "statically_impossible"
276
+ withdrawn = observed && impossible
277
+ if withdrawn
278
+ diagnostics << Records.diagnostic(
279
+ code: "constraint_model_conflict", severity: "warning",
280
+ message: "runtime evidence matched a statically impossible decision-table rule; " \
281
+ "the impossibility claim is withdrawn",
282
+ decision_id: decision_id,
283
+ details: { rule_id: rule[:id], rule_label: rule[:label],
284
+ reachability_reason: rule[:reachability_reason].to_s }
285
+ )
286
+ end
287
+ rule.merge(
288
+ coverage: if observed
289
+ "covered"
290
+ else
291
+ (impossible ? "excluded" : "missing")
292
+ end,
293
+ reachability: observed ? "observed" : rule[:reachability],
294
+ reachability_reason: observed ? nil : rule[:reachability_reason],
295
+ impossible_withdrawn: withdrawn,
296
+ withdrawn_reason: withdrawn ? rule[:reachability_reason] : nil,
297
+ tests: matched.flat_map { |vector| Array(fetch(vector, :test_ids)).map(&:to_s) }.uniq.sort,
298
+ vector_ids: matched.map { |vector| fetch(vector, :id).to_s }.uniq.sort,
299
+ unattributed_count: matched.sum { |vector| fetch(vector, :unattributed_count).to_i }
300
+ )
301
+ end
302
+
303
+ # A runtime observation matches a rule when every required condition value
304
+ # matches and the decision outcome matches. Conditions Ruby skipped may only
305
+ # line up with don't-care positions.
306
+ def matches?(rule, vector)
307
+ return false unless (fetch(vector, :outcome) ? true : false) == rule[:outcome]
308
+
309
+ values = Array(fetch(vector, :values))
310
+ rule[:conditions].each_with_index.all? do |required, index|
311
+ next true if required == DONT_CARE
312
+
313
+ values[index] == (required == TRUE_VALUE)
314
+ end
315
+ end
316
+
317
+ def summary(decision_id:, rules:, diagnostics:, reachability_analyzed: true)
318
+ generated = rules.length
319
+ impossible = rules.count { |rule| rule[:coverage] == "excluded" }
320
+ required = generated - impossible
321
+ covered = rules.count { |rule| rule[:coverage] == "covered" }
322
+ { status: "calculated", reason: nil, decision_id: decision_id,
323
+ schema_version: SCHEMA_VERSION, constraint_analysis_version: CONSTRAINT_ANALYSIS_VERSION,
324
+ rules: rules, generated_rules: generated, impossible_rules: impossible,
325
+ required_rules: required, covered_rules: covered, missing_rules: required - covered,
326
+ coverage_status: coverage_status(covered, required, generated),
327
+ percentage: percentage(covered, required), reachability_analyzed: reachability_analyzed,
328
+ diagnostics: diagnostics }
329
+ end
330
+
331
+ def coverage_status(covered, required, generated)
332
+ return "unexecuted" if generated.zero?
333
+ return "covered" if required.zero? || covered == required
334
+ return "unexecuted" if covered.zero?
335
+
336
+ "partial"
337
+ end
338
+
339
+ def not_calculated(decision_id, reason)
340
+ { status: "not_calculated", reason: reason, decision_id: decision_id,
341
+ schema_version: SCHEMA_VERSION, constraint_analysis_version: CONSTRAINT_ANALYSIS_VERSION,
342
+ rules: [], generated_rules: 0, impossible_rules: 0, required_rules: 0, covered_rules: 0,
343
+ missing_rules: 0,
344
+ coverage_status: reason == "unsupported_decision" ? "unsupported" : "not_calculated",
345
+ percentage: nil, reachability_analyzed: false, diagnostics: [] }
346
+ end
347
+
348
+ # The per-decision row the coverage ladder renders.
349
+ def coverage_entry(table)
350
+ { status: table[:coverage_status], table_status: table[:status], reason: table[:reason],
351
+ covered_rules: table[:covered_rules], required_rules: table[:required_rules],
352
+ generated_rules: table[:generated_rules], impossible_rules: table[:impossible_rules],
353
+ missing_rules: table[:missing_rules], percentage: table[:percentage],
354
+ reachability_analyzed: table[:reachability_analyzed] }
355
+ end
356
+
357
+ def unsupported_coverage_entry
358
+ coverage_entry(not_calculated(nil, "unsupported_decision"))
359
+ end
360
+
361
+ def percentage(covered, required)
362
+ required.zero? ? nil : (covered.to_f / required * 100).round(2)
363
+ end
364
+
365
+ def limit(limits, key, fallback)
366
+ value = fetch(limits, key)
367
+ value.is_a?(Integer) && value.positive? ? value : fallback
368
+ end
369
+
370
+ def fetch(record, key)
371
+ return nil unless record.respond_to?(:key?)
372
+ return record[key] if record.key?(key)
373
+
374
+ record[key.to_s]
375
+ end
376
+ end
377
+ end
@@ -20,10 +20,16 @@ module Branchproof
20
20
  raise ArgumentError, "run_id is required" if run_id.nil? || run_id.to_s.empty?
21
21
 
22
22
  @inventory = inventory
23
+ @decisions_by_id = Array(fetch_value(@inventory, :decisions)).each_with_object({}) do |raw, index|
24
+ decision = symbolize(raw)
25
+ index[decision[:id].to_s] = decision
26
+ end
23
27
  @limits = normalize_limits(limits)
24
28
  @run_id = run_id.to_s
25
29
  @run_ids = [@run_id]
26
30
  @vectors = {}
31
+ @vector_counts_by_decision = Hash.new(0)
32
+ @owner_associations_count = 0
27
33
  @tests = {}
28
34
  @run_payloads = {}
29
35
  @abort_counts = Hash.new(0)
@@ -68,12 +74,13 @@ module Branchproof
68
74
  end
69
75
  decision_id = value[:decision_id].to_s
70
76
  vector_values = condition_values(decision_id, value[:observations])
71
- if new_vector?(decision_id, vector_values,
72
- value[:outcome]) && vector_count(decision_id) >= @limits[:vectors_per_decision]
77
+ outcome = value[:outcome] ? true : false
78
+ vector_id = Branchproof::Records.id([decision_id, vector_values, outcome])
79
+ if new_vector?(vector_id) && vector_count(decision_id) >= @limits[:vectors_per_decision]
73
80
  @limited = true
74
81
  return status("limited", "vectors_per_decision reached")
75
82
  end
76
- vector = vector_for(decision_id, vector_values, value[:outcome] ? true : false)
83
+ vector = vector_for(decision_id, vector_values, outcome, vector_id)
77
84
  new_owner = if value[:test_id]
78
85
  !vector[:test_ids].include?(value[:test_id].to_s)
79
86
  else
@@ -89,6 +96,7 @@ module Branchproof
89
96
  phase_map << value[:phase].to_s if phase_map && !phase_map.include?(value[:phase].to_s)
90
97
  vector[:unattributed_count] += 1 unless value[:test_id]
91
98
  vector[:count] += 1
99
+ @owner_associations_count += 1 if new_owner
92
100
  if value[:test_id] && @tests.key?(value[:test_id].to_s)
93
101
  test = @tests[value[:test_id].to_s]
94
102
  phase = value[:phase].to_s
@@ -194,18 +202,17 @@ module Branchproof
194
202
  def validate_execution(value)
195
203
  return "execution must be a hash" unless value.is_a?(Hash)
196
204
 
197
- %i[run_id execution_id decision_id test_id phase owner observations outcome status].each do |key|
205
+ %i[run_id decision_id test_id phase observations outcome status].each do |key|
198
206
  return "missing #{key}" unless value.key?(key)
199
207
  end
200
208
  return "run mismatch" unless value[:run_id].to_s == @run_id
201
- return "invalid owner" unless value[:owner].is_a?(Hash)
202
209
  return "invalid status" unless %w[completed aborted invalid].include?(value[:status].to_s)
203
210
  return "invalid phase" unless %w[setup body teardown suite unattributed].include?(value[:phase].to_s)
204
211
  return "invalid observations" unless value[:observations].is_a?(Array) && value[:observations].all? do |pair|
205
212
  pair.is_a?(Array) && pair.length == 2 && pair[0].is_a?(Integer) && [true, false].include?(pair[1])
206
213
  end
207
214
 
208
- decision = decisions.find { |d| d[:id].to_s == value[:decision_id].to_s }
215
+ decision = @decisions_by_id[value[:decision_id].to_s]
209
216
  return "unknown decision" unless decision
210
217
 
211
218
  dimensions = alternative_decision?(decision) ? Array(decision[:alternatives]) : Array(decision[:conditions])
@@ -224,7 +231,7 @@ module Branchproof
224
231
  def valid_trace?(decision, observations, outcome)
225
232
  return valid_alternative_trace?(decision, observations, outcome) if alternative_decision?(decision)
226
233
 
227
- tree = symbolize(decision[:tree])
234
+ tree = decision[:tree]
228
235
  unless tree
229
236
  return observations.map(&:first) == observations.map(&:first).sort &&
230
237
  (outcome ? true : false) == evaluate_fallback(observations)
@@ -239,7 +246,6 @@ module Branchproof
239
246
  end
240
247
 
241
248
  def replay_tree(node, observations, cursor)
242
- node = symbolize(node)
243
249
  type = node[:type].to_s
244
250
  if type == "atom"
245
251
  pair = observations[cursor]
@@ -280,26 +286,42 @@ module Branchproof
280
286
  values
281
287
  end
282
288
 
283
- def vector_for(decision_id, values, outcome)
284
- id = Branchproof::Records.id([decision_id, values, outcome])
285
- @vectors[id] ||= { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
286
- test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
289
+ def vector_for(decision_id, values, outcome, id)
290
+ existing = @vectors[id]
291
+ return existing if existing
292
+
293
+ vector = { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
294
+ test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
295
+ @vectors[id] = vector
296
+ @vector_counts_by_decision[decision_id] += 1
297
+ vector
287
298
  end
288
299
 
289
300
  def merge_vector(raw)
290
301
  vector = symbolize(raw)
291
- existing = @vectors[vector[:id].to_s]
302
+ id = vector[:id].to_s
303
+ existing = @vectors[id]
292
304
  if existing
293
- existing[:test_ids] |= Array(vector[:test_ids]).map(&:to_s)
305
+ new_test_ids = Array(vector[:test_ids]).map(&:to_s) - existing[:test_ids]
306
+ existing[:test_ids] |= new_test_ids
294
307
  vector.fetch(:phases_by_test, {}).each do |test, phases|
295
308
  existing[:phases_by_test][test.to_s] = (existing[:phases_by_test][test.to_s] || []) | phases
296
309
  end
310
+ was_unattributed = existing[:unattributed_count].positive?
297
311
  existing[:count] += vector[:count].to_i
298
312
  existing[:unattributed_count] += vector[:unattributed_count].to_i
313
+ @owner_associations_count += new_test_ids.length
314
+ @owner_associations_count += 1 if !was_unattributed && existing[:unattributed_count].positive?
299
315
  else
300
- @vectors[vector[:id].to_s] = { id: vector[:id].to_s, decision_id: vector[:decision_id].to_s,
301
- values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: Array(vector[:test_ids]).map(&:to_s),
302
- phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: vector[:unattributed_count].to_i, count: vector[:count].to_i }
316
+ decision_id = vector[:decision_id].to_s
317
+ test_ids = Array(vector[:test_ids]).map(&:to_s)
318
+ unattributed_count = vector[:unattributed_count].to_i
319
+ @vectors[id] = { id: id, decision_id: decision_id,
320
+ values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: test_ids,
321
+ phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: unattributed_count, count: vector[:count].to_i }
322
+ @vector_counts_by_decision[decision_id] += 1
323
+ @owner_associations_count += test_ids.length
324
+ @owner_associations_count += 1 if unattributed_count.positive?
303
325
  end
304
326
  end
305
327
 
@@ -314,14 +336,14 @@ module Branchproof
314
336
  end
315
337
 
316
338
  def condition_shapes
317
- decisions.to_h do |decision|
339
+ @decisions_by_id.transform_values do |decision|
318
340
  shape = { conditions: Array(decision[:conditions]).map { |condition| symbolize(condition) },
319
341
  tree: symbolize(decision[:tree]) }
320
342
  if alternative_decision?(decision)
321
343
  shape[:kind] = decision[:kind].to_s
322
344
  shape[:alternatives] = Array(decision[:alternatives]).map { |alternative| symbolize(alternative) }
323
345
  end
324
- [decision[:id].to_s, shape]
346
+ shape
325
347
  end
326
348
  end
327
349
 
@@ -348,7 +370,7 @@ module Branchproof
348
370
  vector = symbolize(raw)
349
371
  return "invalid vector" unless vector[:id] && vector[:decision_id] && vector[:values].is_a?(Array)
350
372
 
351
- decision = decisions.find { |item| item[:id].to_s == vector[:decision_id].to_s }
373
+ decision = @decisions_by_id[vector[:decision_id].to_s]
352
374
  return "unknown decision" unless decision
353
375
 
354
376
  expected_values = decision_dimension_count(decision)
@@ -396,7 +418,7 @@ module Branchproof
396
418
  end
397
419
 
398
420
  def decision_dimension_count(decision_or_id)
399
- decision = decision_or_id.is_a?(Hash) ? decision_or_id : decisions.find { |item| item[:id].to_s == decision_or_id.to_s }
421
+ decision = decision_or_id.is_a?(Hash) ? decision_or_id : @decisions_by_id[decision_or_id.to_s]
400
422
  return 0 unless decision
401
423
 
402
424
  alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
@@ -420,16 +442,11 @@ module Branchproof
420
442
  end
421
443
  end
422
444
 
423
- def decisions = Array(fetch_value(@inventory, :decisions)).map { symbolize(_1) }
424
- def decision_conditions(id) = (decisions.find { |d| d[:id].to_s == id.to_s } || {}).fetch(:conditions, [])
425
- def vector_count(id) = @vectors.values.count { |v| v[:decision_id] == id.to_s }
426
- def new_vector?(id, values, outcome) = !@vectors.key?(Branchproof::Records.id([id, values, outcome]))
445
+ def decision_conditions(id) = (@decisions_by_id[id.to_s] || {}).fetch(:conditions, [])
446
+ def vector_count(id) = @vector_counts_by_decision[id.to_s]
447
+ def new_vector?(id) = !@vectors.key?(id)
427
448
 
428
- def owner_associations
429
- @vectors.values.sum do |v|
430
- v[:test_ids].length + (v[:unattributed_count].positive? ? 1 : 0)
431
- end
432
- end
449
+ def owner_associations = @owner_associations_count
433
450
 
434
451
  def incoming_owner_associations(incoming)
435
452
  owners = @vectors.each_with_object({}) do |(_id, vector), result|
@@ -500,7 +517,8 @@ module Branchproof
500
517
  def capture_state
501
518
  { vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
502
519
  run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
503
- abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete }
520
+ abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete,
521
+ vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count }
504
522
  end
505
523
 
506
524
  def restore_state(state)
@@ -510,6 +528,8 @@ module Branchproof
510
528
  @run_payloads = state[:run_payloads]
511
529
  @diagnostics = state[:diagnostics]
512
530
  @abort_counts = state[:abort_counts]
531
+ @vector_counts_by_decision = state[:vector_counts_by_decision]
532
+ @owner_associations_count = state[:owner_associations_count]
513
533
  @limited = state[:limited]
514
534
  @attribution_complete = state[:attribution_complete]
515
535
  end
@@ -8,36 +8,36 @@ module Branchproof
8
8
  module FlowInstrumentation
9
9
  private
10
10
 
11
- def render_flow(bytes, decision, nested)
11
+ def render_flow(bytes, decision, nested, encloses)
12
12
  metadata = decision.fetch(:instrumentation)
13
13
  case metadata.fetch(:type)
14
14
  when "safe_navigation"
15
15
  receiver = metadata.fetch(:receiver)
16
- replacements = [flow_replacement(bytes, receiver, nested) do |expression|
16
+ replacements = [flow_replacement(bytes, receiver, nested, encloses) do |expression|
17
17
  "#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
18
18
  end]
19
- flow_fragments(bytes, decision, nested, replacements)
19
+ flow_fragments(bytes, decision, nested, replacements, encloses)
20
20
  when "assignment"
21
21
  rhs = metadata.fetch(:rhs)
22
- replacements = [flow_replacement(bytes, rhs, nested) do |expression|
22
+ replacements = [flow_replacement(bytes, rhs, nested, encloses) do |expression|
23
23
  "(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
24
24
  end]
25
- expression = flow_fragments(bytes, decision, nested, replacements)
25
+ expression = flow_fragments(bytes, decision, nested, replacements, encloses)
26
26
  flow_frame(decision[:id], expression, default_path: 0)
27
27
  when "case"
28
- render_case_flow(bytes, decision, nested, metadata)
28
+ render_case_flow(bytes, decision, nested, metadata, encloses)
29
29
  when "case_match"
30
- render_pattern_flow(bytes, decision, nested, metadata)
30
+ render_pattern_flow(bytes, decision, nested, metadata, encloses)
31
31
  else
32
32
  raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
33
33
  end
34
34
  end
35
35
 
36
- def render_case_flow(bytes, decision, nested, metadata)
36
+ def render_case_flow(bytes, decision, nested, metadata, encloses)
37
37
  identifier = decision[:id].inspect
38
38
  runtime = self.class::RUNTIME
39
39
  replacements = metadata.fetch(:candidates).map do |candidate|
40
- flow_replacement(bytes, candidate, nested) do |expression|
40
+ flow_replacement(bytes, candidate, nested, encloses) do |expression|
41
41
  "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
42
42
  end
43
43
  end
@@ -55,10 +55,10 @@ module Branchproof
55
55
  replacements << { start: metadata.fetch(:end_start), length: 0,
56
56
  text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
57
57
  end
58
- flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
58
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
59
59
  end
60
60
 
61
- def render_pattern_flow(bytes, decision, nested, metadata)
61
+ def render_pattern_flow(bytes, decision, nested, metadata, encloses)
62
62
  identifier = decision[:id].inspect
63
63
  runtime = self.class::RUNTIME
64
64
  replacements = metadata.fetch(:branches).map do |branch|
@@ -71,27 +71,27 @@ module Branchproof
71
71
  replacements << { start: alternative[:insert_at], length: 0,
72
72
  text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
73
73
  end
74
- flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
74
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
75
75
  end
76
76
 
77
- def flow_replacement(bytes, location, nested)
77
+ def flow_replacement(bytes, location, nested, encloses)
78
78
  start = location.fetch(:byte_start)
79
79
  length = location.fetch(:byte_length)
80
- { start: start, length: length, text: yield(render_children(bytes, start, length, nested)) }
80
+ { start: start, length: length, text: yield(render_children(bytes, start, length, nested, encloses)) }
81
81
  end
82
82
 
83
- def flow_fragments(bytes, decision, nested, replacements)
83
+ def flow_fragments(bytes, decision, nested, replacements, encloses)
84
84
  cursor = decision.fetch(:byte_start)
85
85
  finish = cursor + decision.fetch(:byte_length)
86
86
  chunks = []
87
87
  replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
88
88
  raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
89
89
 
90
- chunks << render_children(bytes, cursor, edit[:start] - cursor, nested)
90
+ chunks << render_children(bytes, cursor, edit[:start] - cursor, nested, encloses)
91
91
  chunks << edit[:text]
92
92
  cursor = edit[:start] + edit[:length]
93
93
  end
94
- chunks << render_children(bytes, cursor, finish - cursor, nested)
94
+ chunks << render_children(bytes, cursor, finish - cursor, nested, encloses)
95
95
  chunks.join
96
96
  end
97
97