latticerb 0.1.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.
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "model"
6
+
7
+ module Lattice
8
+ class ConstraintError < StandardError; end
9
+
10
+ # Compiles model constraints into invalid-pair / requirement closures and
11
+ # answers feasibility questions over partial assignments. Assignments are
12
+ # referenced as `[parameter_index, value_index]` pairs; partial assignments
13
+ # are `{parameter_index => value_index}` hashes.
14
+ class ConstraintEngine
15
+ EMPTY_SET = Set.new.freeze
16
+
17
+ attr_reader :model, :forced_interactions
18
+
19
+ def initialize(model)
20
+ @model = model
21
+ @parameter_index = model.parameters.each_with_index.to_h { |parameter, index| [parameter.name, index] }
22
+ @value_index = {}
23
+ model.parameters.each_with_index do |parameter, parameter_idx|
24
+ parameter.values.each_with_index do |value, value_idx|
25
+ @value_index[[parameter_idx, value]] = value_idx
26
+ end
27
+ end
28
+ @invalid_pairs = {}
29
+ @requires = {}
30
+ @higher_order = []
31
+ @forced_interactions = []
32
+ @feasibility_cache = {}
33
+ compile_constraints
34
+ compute_implied_pairs
35
+ validate_compiled_constraints
36
+ end
37
+
38
+ def conflicts?(left, right)
39
+ invalid_pairs_for(to_ref(left)).include?(to_ref(right))
40
+ end
41
+
42
+ def implied_requirements(assignment)
43
+ requires_for(to_ref(assignment)).map { |ref| from_ref(ref) }.sort
44
+ end
45
+
46
+ def can_complete?(partial)
47
+ normalized = normalize_partial(partial.dup)
48
+ return false if normalized.nil?
49
+
50
+ signature = normalized.sort
51
+ cached = @feasibility_cache[signature]
52
+ return cached unless cached.nil?
53
+
54
+ result = !backtrack_complete(normalized, rng: nil, failures: Set.new).nil?
55
+ @feasibility_cache[signature] = result
56
+ result
57
+ end
58
+
59
+ def complete_assignment(partial, rng: nil)
60
+ normalized = normalize_partial(partial.dup)
61
+ return nil if normalized.nil?
62
+
63
+ backtrack_complete(normalized, rng:, failures: Set.new)
64
+ end
65
+
66
+ def complete_assignment_valid?(row)
67
+ partial = row.each_with_index.to_h { |value_index, parameter_index| [parameter_index, value_index] }
68
+ normalized = normalize_partial(partial)
69
+ return false if normalized.nil? || normalized.length != model.parameters.length
70
+
71
+ @higher_order.each do |antecedents, forbidden|
72
+ if antecedents.all? { |parameter_index, value_index| normalized[parameter_index] == value_index }
73
+ return false if normalized[forbidden[0]] == forbidden[1]
74
+ end
75
+ end
76
+ true
77
+ end
78
+
79
+ private
80
+
81
+ def compile_constraints
82
+ model.constraints.each do |constraint|
83
+ case constraint
84
+ when InvalidPairConstraint
85
+ add_invalid_pair(to_ref(constraint.if_assignment), to_ref(constraint.then_not))
86
+ when BidirectionalConstraint
87
+ left_ref, right_ref = constraint.pair.map { |assignment| to_ref(assignment) }
88
+ add_requirement(left_ref, right_ref)
89
+ add_requirement(right_ref, left_ref)
90
+ exclude_other_values(left_ref, right_ref)
91
+ exclude_other_values(right_ref, left_ref)
92
+ when ForwardDependencyConstraint
93
+ source_ref = to_ref(constraint.source)
94
+ target_ref = to_ref(constraint.target)
95
+ add_requirement(source_ref, target_ref)
96
+ exclude_other_values(source_ref, target_ref)
97
+ when ConditionalConstraint
98
+ compile_conditional(constraint)
99
+ when ForcedConstraint
100
+ @forced_interactions << constraint.assignments.map { |assignment| to_ref(assignment) }
101
+ when HigherOrderConstraint
102
+ antecedents = constraint.if_all.map { |assignment| to_ref(assignment) }
103
+ @higher_order << [antecedents, to_ref(constraint.then_not)]
104
+ end
105
+ end
106
+ end
107
+
108
+ def compile_conditional(constraint)
109
+ parent_index = @parameter_index.fetch(constraint.parent)
110
+ child_index = @parameter_index.fetch(constraint.parameter)
111
+ parent = model.parameters[parent_index]
112
+ child = model.parameters[child_index]
113
+ applicable_values = Set.new(constraint.applies_when)
114
+ na_ref = [child_index, @value_index.fetch([child_index, NA_VALUE])]
115
+
116
+ parent.values.each_with_index do |parent_value, parent_value_index|
117
+ parent_ref = [parent_index, parent_value_index]
118
+ if applicable_values.include?(parent_value)
119
+ add_invalid_pair(parent_ref, na_ref)
120
+ next
121
+ end
122
+
123
+ child.values.each_with_index do |child_value, child_value_index|
124
+ next if child_value == NA_VALUE
125
+
126
+ add_invalid_pair(parent_ref, [child_index, child_value_index])
127
+ end
128
+ end
129
+ end
130
+
131
+ def compute_implied_pairs
132
+ changed = true
133
+ all_assignments = all_assignment_refs
134
+ while changed
135
+ changed = false
136
+
137
+ all_assignments.each do |assignment|
138
+ implied = requires_for(assignment).dup
139
+ implied.to_a.each do |required|
140
+ implied.merge(requires_for(required))
141
+ end
142
+ unless (implied - requires_for(assignment)).empty?
143
+ (@requires[assignment] ||= Set.new).merge(implied)
144
+ changed = true
145
+ end
146
+ end
147
+
148
+ all_assignments.each do |assignment|
149
+ additions = Set.new
150
+ invalid_pairs_for(assignment).to_a.each do |conflicting|
151
+ additions.merge(requires_for(conflicting))
152
+ end
153
+ requires_for(assignment).to_a.each do |required|
154
+ additions.merge(invalid_pairs_for(required))
155
+ end
156
+ additions.delete(assignment)
157
+ additions.each do |addition|
158
+ unless invalid_pairs_for(assignment).include?(addition)
159
+ add_invalid_pair(assignment, addition)
160
+ changed = true
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ def validate_compiled_constraints
168
+ all_assignment_refs.each do |assignment|
169
+ if invalid_pairs_for(assignment).include?(assignment)
170
+ readable = from_ref(assignment)
171
+ raise ConstraintError, "Constraint closure makes `#{readable.parameter}=#{readable.value}` impossible."
172
+ end
173
+
174
+ requires_for(assignment).each do |required|
175
+ if assignment[0] == required[0] && assignment[1] != required[1]
176
+ left = from_ref(assignment)
177
+ right = from_ref(required)
178
+ raise ConstraintError,
179
+ "Constraint closure requires incompatible values `#{left.parameter}=#{left.value}` and `#{right.parameter}=#{right.value}`."
180
+ end
181
+ if invalid_pairs_for(assignment).include?(required)
182
+ left = from_ref(assignment)
183
+ right = from_ref(required)
184
+ raise ConstraintError,
185
+ "Constraint closure makes `#{left.parameter}=#{left.value}` conflict with its own requirement `#{right.parameter}=#{right.value}`."
186
+ end
187
+ end
188
+ end
189
+
190
+ @forced_interactions.each do |forced|
191
+ partial = forced.to_h { |parameter_index, value_index| [parameter_index, value_index] }
192
+ next unless complete_assignment(partial, rng: nil).nil?
193
+
194
+ assignments = forced.map do |parameter_index, value_index|
195
+ parameter = model.parameters[parameter_index]
196
+ "#{parameter.name}=#{parameter.values[value_index]}"
197
+ end.join(", ")
198
+ raise ConstraintError, "Forced interaction is not feasible: #{assignments}."
199
+ end
200
+ end
201
+
202
+ def normalize_partial(partial)
203
+ normalized = partial.dup
204
+ changed = true
205
+ while changed
206
+ changed = false
207
+ return nil unless partial_consistent?(normalized)
208
+
209
+ normalized.to_a.each do |parameter_index, value_index|
210
+ requires_for([parameter_index, value_index]).each do |required_parameter, required_value|
211
+ current = normalized[required_parameter]
212
+ if current.nil?
213
+ normalized[required_parameter] = required_value
214
+ changed = true
215
+ elsif current != required_value
216
+ return nil
217
+ end
218
+ end
219
+ end
220
+ end
221
+ return nil unless partial_consistent?(normalized)
222
+
223
+ normalized
224
+ end
225
+
226
+ def partial_consistent?(partial)
227
+ partial.each do |parameter_index, value_index|
228
+ partial.each do |other_parameter, other_value|
229
+ next if parameter_index == other_parameter
230
+
231
+ return false if invalid_pairs_for([parameter_index, value_index]).include?([other_parameter, other_value])
232
+ end
233
+ end
234
+
235
+ @higher_order.each do |antecedents, forbidden|
236
+ if antecedents.all? { |parameter_index, value_index| partial[parameter_index] == value_index }
237
+ return false if partial[forbidden[0]] == forbidden[1]
238
+ end
239
+ end
240
+ true
241
+ end
242
+
243
+ def backtrack_complete(partial, rng:, failures:)
244
+ signature = partial.sort
245
+ return nil if failures.include?(signature)
246
+
247
+ if partial.length == model.parameters.length
248
+ row = (0...model.parameters.length).map { |index| partial.fetch(index) }
249
+ return complete_assignment_valid?(row) ? row : nil
250
+ end
251
+
252
+ next_parameter, values = next_parameter_values(partial)
253
+ if next_parameter.nil?
254
+ failures.add(signature)
255
+ return nil
256
+ end
257
+
258
+ ordered_values(next_parameter, values, rng).each do |value|
259
+ candidate = partial.dup
260
+ candidate[next_parameter] = value
261
+ normalized = normalize_partial(candidate)
262
+ next if normalized.nil?
263
+
264
+ result = backtrack_complete(normalized, rng:, failures:)
265
+ return result unless result.nil?
266
+ end
267
+
268
+ failures.add(signature)
269
+ nil
270
+ end
271
+
272
+ def next_parameter_values(partial)
273
+ best_parameter = nil
274
+ best_values = []
275
+ best_size = nil
276
+
277
+ model.parameters.each_with_index do |parameter, parameter_index|
278
+ next if partial.key?(parameter_index)
279
+
280
+ feasible = []
281
+ (0...parameter.values.length).each do |value_index|
282
+ candidate = partial.dup
283
+ candidate[parameter_index] = value_index
284
+ feasible << value_index unless normalize_partial(candidate).nil?
285
+ end
286
+
287
+ return [nil, []] if feasible.empty?
288
+
289
+ if best_size.nil? || feasible.length < best_size
290
+ best_parameter = parameter_index
291
+ best_values = feasible
292
+ best_size = feasible.length
293
+ end
294
+ end
295
+
296
+ [best_parameter, best_values]
297
+ end
298
+
299
+ def ordered_values(parameter_index, values, rng)
300
+ parameter = model.parameters[parameter_index]
301
+ if rng.nil?
302
+ return values.sort_by { |value_index| [-parameter.normalized_weights[value_index], value_index] }
303
+ end
304
+
305
+ values
306
+ .map { |value_index| [rng.random / [parameter.normalized_weights[value_index], 1e-9].max, value_index] }
307
+ .sort
308
+ .map { |_, value_index| value_index }
309
+ end
310
+
311
+ def exclude_other_values(source, required)
312
+ target_parameter = model.parameters[required[0]]
313
+ (0...target_parameter.values.length).each do |value_index|
314
+ next if value_index == required[1]
315
+
316
+ add_invalid_pair(source, [required[0], value_index])
317
+ end
318
+ end
319
+
320
+ def add_invalid_pair(left, right)
321
+ (@invalid_pairs[left] ||= Set.new).add(right)
322
+ (@invalid_pairs[right] ||= Set.new).add(left)
323
+ end
324
+
325
+ def add_requirement(source, target)
326
+ (@requires[source] ||= Set.new).add(target)
327
+ end
328
+
329
+ def invalid_pairs_for(ref)
330
+ @invalid_pairs[ref] || EMPTY_SET
331
+ end
332
+
333
+ def requires_for(ref)
334
+ @requires[ref] || EMPTY_SET
335
+ end
336
+
337
+ def all_assignment_refs
338
+ refs = []
339
+ model.parameters.each_with_index do |parameter, parameter_index|
340
+ (0...parameter.values.length).each do |value_index|
341
+ refs << [parameter_index, value_index]
342
+ end
343
+ end
344
+ refs
345
+ end
346
+
347
+ def to_ref(assignment)
348
+ parameter_index = @parameter_index.fetch(assignment.parameter)
349
+ [parameter_index, @value_index.fetch([parameter_index, assignment.value])]
350
+ end
351
+
352
+ def from_ref(ref)
353
+ parameter = model.parameters[ref[0]]
354
+ Assignment.new(parameter: parameter.name, value: parameter.values[ref[1]])
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+
6
+ require_relative "ipog"
7
+ require_relative "model"
8
+
9
+ module Lattice
10
+ # Output rendering. Table, JSON, CSV, and summary formats are byte-identical
11
+ # to the Python implementation's output for the same model and result.
12
+ module Formatters
13
+ extend self
14
+
15
+ def render_output(model, result, output_format:, seed:)
16
+ case output_format
17
+ when "table" then format_table(model, result)
18
+ when "json" then format_json(model, result, seed:)
19
+ when "csv" then format_csv(model, result)
20
+ when "summary" then format_summary(model, result)
21
+ else
22
+ raise ArgumentError, "Unknown format `#{output_format}`."
23
+ end
24
+ end
25
+
26
+ def format_table(model, result)
27
+ rows = named_rows(model, result)
28
+ headers = ["#"] + model.parameter_names
29
+ body = rows.each_with_index.map do |row, index|
30
+ [(index + 1).to_s] + model.parameter_names.map { |name| row[name] }
31
+ end
32
+ widths = headers.each_index.map do |column|
33
+ ([headers[column].length] + body.map { |record| record[column].length }).max
34
+ end
35
+
36
+ lines = [headline(model, result), ""]
37
+ lines.concat(milestone_lines(result))
38
+ lines << ""
39
+ lines << headers.each_index.map do |column|
40
+ column.zero? ? headers[column].rjust(widths[column]) : headers[column].ljust(widths[column])
41
+ end.join(" | ")
42
+ lines << widths.map { |width| "-" * width }.join("-|-")
43
+
44
+ body.each do |record|
45
+ lines << record.each_index.map do |column|
46
+ column.zero? ? record[column].rjust(widths[column]) : record[column].ljust(widths[column])
47
+ end.join(" | ")
48
+ end
49
+ lines.join("\n")
50
+ end
51
+
52
+ def format_summary(model, result)
53
+ lines = [headline(model, result), ""]
54
+ lines.concat(milestone_lines(result))
55
+ lines.join("\n")
56
+ end
57
+
58
+ def format_json(model, result, seed:)
59
+ last_point = result.coverage_curve.last
60
+ payload = {
61
+ "meta" => {
62
+ "model_name" => model.model_name,
63
+ "strength" => model.strength,
64
+ "test_count" => result.rows.length,
65
+ "exhaustive_count" => model.exhaustive_count,
66
+ "reduction" => reduction_label(model.exhaustive_count, result.rows.length),
67
+ "coverage" => last_point ? format("%.1f%%", last_point.cumulative_pct) : "0.0%",
68
+ "seed" => seed
69
+ },
70
+ "coverage_curve" => result.coverage_curve.map do |point|
71
+ { "scenario" => point.scenario, "cumulative_pct" => point.cumulative_pct }
72
+ end,
73
+ "scenarios" => named_rows(model, result).each_with_index.map do |row, index|
74
+ {
75
+ "id" => index + 1,
76
+ "values" => model.parameter_names.to_h { |name| [name, row[name]] }
77
+ }
78
+ end
79
+ }
80
+ JSON.pretty_generate(payload, ascii_only: true)
81
+ end
82
+
83
+ def format_csv(model, result)
84
+ CSV.generate(row_sep: "\r\n") do |csv|
85
+ csv << model.parameter_names
86
+ named_rows(model, result).each do |row|
87
+ csv << model.parameter_names.map { |name| row[name] }
88
+ end
89
+ end.strip
90
+ end
91
+
92
+ def headline(model, result)
93
+ "Lattice — #{result.rows.length} scenarios (from #{thousands(model.exhaustive_count)} possible)"
94
+ end
95
+
96
+ def milestone_lines(result)
97
+ [50.0, 75.0, 90.0, 100.0].filter_map do |threshold|
98
+ point = result.coverage_curve.find { |candidate| candidate.cumulative_pct >= threshold }
99
+ " #{threshold.to_i}% coverage at scenario ##{point.scenario}" if point
100
+ end
101
+ end
102
+
103
+ def named_rows(model, result)
104
+ result.rows.map do |row|
105
+ model.parameters.each_with_index.to_h do |parameter, index|
106
+ [parameter.name, parameter.values[row[index]]]
107
+ end
108
+ end
109
+ end
110
+
111
+ def reduction_label(exhaustive_count, scenario_count)
112
+ return "inf" if scenario_count.zero?
113
+
114
+ reduction = exhaustive_count.to_f / scenario_count
115
+ if (reduction - reduction.round).abs < 1e-9
116
+ "#{reduction.round(half: :even)}x"
117
+ else
118
+ format("%.1fx", reduction)
119
+ end
120
+ end
121
+
122
+ def thousands(count)
123
+ count.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "constraints"
6
+ require_relative "mersenne_twister"
7
+
8
+ module Lattice
9
+ CoveragePoint = Data.define(:scenario, :cumulative_pct)
10
+ GenerationResult = Data.define(:rows, :coverage_curve, :total_valid_tuples)
11
+ GenerationProgress = Data.define(:event, :scenario, :covered, :total, :cumulative_pct, :uncovered)
12
+
13
+ # Greedy covering-array generation (IPOG-style): enumerate every valid
14
+ # t-way interaction, then repeatedly extend the lowest uncovered tuple into
15
+ # the highest-scoring full row from a seeded candidate pool. Deterministic
16
+ # for a given seed, and row-for-row identical to the Python implementation.
17
+ module IPOG
18
+ extend self
19
+
20
+ def generate_covering_array(
21
+ model,
22
+ strength: nil,
23
+ seed: 42,
24
+ pool_size: 50,
25
+ progress: nil,
26
+ max_rows: nil,
27
+ stop_after_coverage: nil
28
+ )
29
+ actual_strength = strength || model.strength
30
+ raise ArgumentError, "`pool_size` must be at least 1." if pool_size < 1
31
+ raise ArgumentError, "`max_rows` must be at least 1." if !max_rows.nil? && max_rows < 1
32
+
33
+ coverage_target = coverage_target_for(stop_after_coverage)
34
+
35
+ engine = ConstraintEngine.new(model)
36
+ raise ArgumentError, "Model has no valid full assignments." unless engine.can_complete?({})
37
+
38
+ valid_tuples = enumerate_valid_tuples(model, engine, actual_strength, progress:)
39
+ uncovered = Set.new(valid_tuples)
40
+ rows = []
41
+ coverage_points = []
42
+ rng = MersenneTwister.new(seed)
43
+ limit_reached = false
44
+ progress&.call(
45
+ GenerationProgress.new(
46
+ event: "enumerated",
47
+ scenario: 0,
48
+ covered: 0,
49
+ total: valid_tuples.length,
50
+ cumulative_pct: coverage_pct(0, valid_tuples.length),
51
+ uncovered: uncovered.length
52
+ )
53
+ )
54
+
55
+ record_row = lambda do |row|
56
+ newly_covered = covered_tuples(row, actual_strength, uncovered)
57
+ rows << row
58
+ uncovered.subtract(newly_covered)
59
+ covered = valid_tuples.length - uncovered.length
60
+ cumulative_pct = coverage_pct(covered, valid_tuples.length)
61
+ coverage_points << CoveragePoint.new(scenario: rows.length, cumulative_pct:)
62
+ progress&.call(
63
+ GenerationProgress.new(
64
+ event: "scenario",
65
+ scenario: rows.length,
66
+ covered:,
67
+ total: valid_tuples.length,
68
+ cumulative_pct:,
69
+ uncovered: uncovered.length
70
+ )
71
+ )
72
+ if should_stop?(rows.length, cumulative_pct, max_rows, coverage_target)
73
+ progress&.call(
74
+ GenerationProgress.new(
75
+ event: "stopped",
76
+ scenario: rows.length,
77
+ covered:,
78
+ total: valid_tuples.length,
79
+ cumulative_pct:,
80
+ uncovered: uncovered.length
81
+ )
82
+ )
83
+ true
84
+ else
85
+ false
86
+ end
87
+ end
88
+
89
+ engine.forced_interactions.each do |forced|
90
+ partial = forced.to_h { |parameter_index, value_index| [parameter_index, value_index] }
91
+ row = best_candidate_row(model, engine, partial, uncovered, actual_strength, pool_size, rng)
92
+ next if row.nil? || rows.include?(row)
93
+
94
+ if record_row.call(row)
95
+ limit_reached = true
96
+ break
97
+ end
98
+ end
99
+
100
+ while !uncovered.empty? && !limit_reached
101
+ seed_tuple = uncovered.min
102
+ partial = seed_tuple.to_h { |parameter_index, value_index| [parameter_index, value_index] }
103
+ row = best_candidate_row(model, engine, partial, uncovered, actual_strength, pool_size, rng)
104
+ raise "Unable to complete uncovered tuple: #{seed_tuple.inspect}" if row.nil?
105
+
106
+ if record_row.call(row)
107
+ limit_reached = true
108
+ break
109
+ end
110
+ end
111
+
112
+ GenerationResult.new(
113
+ rows: rows.freeze,
114
+ coverage_curve: coverage_points.freeze,
115
+ total_valid_tuples: valid_tuples.length
116
+ )
117
+ end
118
+
119
+ def enumerate_valid_tuples(model, engine, strength, progress: nil)
120
+ tuples = []
121
+ total_candidates = candidate_tuple_count(model, strength)
122
+ checked = 0
123
+ report = lambda do
124
+ progress&.call(
125
+ GenerationProgress.new(
126
+ event: "enumerating",
127
+ scenario: 0,
128
+ covered: checked,
129
+ total: total_candidates,
130
+ cumulative_pct: coverage_pct(checked, total_candidates),
131
+ uncovered: [0, total_candidates - checked].max
132
+ )
133
+ )
134
+ end
135
+
136
+ (0...model.parameters.length).to_a.combination(strength) do |parameter_combo|
137
+ domains = parameter_combo.map { |parameter_index| (0...model.parameters[parameter_index].values.length).to_a }
138
+ domains[0].product(*domains[1..]) do |value_combo|
139
+ checked += 1
140
+ partial = parameter_combo.each_with_index.to_h { |parameter_index, offset| [parameter_index, value_combo[offset]] }
141
+ tuples << parameter_combo.zip(value_combo) if engine.can_complete?(partial)
142
+ report.call if progress && (checked % 1000).zero?
143
+ end
144
+ end
145
+ report.call if progress
146
+ tuples
147
+ end
148
+
149
+ def candidate_tuple_count(model, strength)
150
+ total = 0
151
+ (0...model.parameters.length).to_a.combination(strength) do |parameter_combo|
152
+ total += parameter_combo.inject(1) { |count, parameter_index| count * model.parameters[parameter_index].values.length }
153
+ end
154
+ total
155
+ end
156
+
157
+ def coverage_target_for(stop_after_coverage)
158
+ return nil if stop_after_coverage.nil?
159
+
160
+ unless stop_after_coverage.positive? && stop_after_coverage <= 100
161
+ raise ArgumentError, "`stop_after_coverage` must be in the range (0, 100]."
162
+ end
163
+ return nil if stop_after_coverage >= 100
164
+
165
+ stop_after_coverage
166
+ end
167
+
168
+ def should_stop?(row_count, cumulative_pct, max_rows, coverage_target)
169
+ return true if !max_rows.nil? && row_count >= max_rows
170
+ return true if !coverage_target.nil? && cumulative_pct >= coverage_target
171
+
172
+ false
173
+ end
174
+
175
+ def best_candidate_row(model, engine, partial, uncovered, strength, pool_size, rng)
176
+ candidates = {}
177
+ deterministic = engine.complete_assignment(partial, rng: nil)
178
+ candidates[deterministic] = score_row(model, deterministic, uncovered, strength) unless deterministic.nil?
179
+
180
+ (pool_size - 1).times do
181
+ attempt_rng = MersenneTwister.new(rng.getrandbits(64))
182
+ candidate = engine.complete_assignment(partial, rng: attempt_rng)
183
+ next if candidate.nil?
184
+
185
+ score = score_row(model, candidate, uncovered, strength)
186
+ previous = candidates[candidate]
187
+ candidates[candidate] = score if previous.nil? || (score <=> previous).positive?
188
+ end
189
+
190
+ return nil if candidates.empty?
191
+
192
+ candidates.max_by { |row, score| [score[0], score[1], reverse_lex(row)] }[0]
193
+ end
194
+
195
+ def score_row(model, row, uncovered, strength)
196
+ newly_covered = covered_tuples(row, strength, uncovered).length
197
+ weight_score = row.each_with_index.inject(0.0) do |acc, (value_index, parameter_index)|
198
+ acc + model.parameters[parameter_index].normalized_weights[value_index]
199
+ end
200
+ [newly_covered, weight_score]
201
+ end
202
+
203
+ def covered_tuples(row, strength, candidate_tuples)
204
+ covered = Set.new
205
+ (0...row.length).to_a.combination(strength) do |parameter_combo|
206
+ tuple_key = parameter_combo.map { |parameter_index| [parameter_index, row[parameter_index]] }
207
+ covered.add(tuple_key) if candidate_tuples.include?(tuple_key)
208
+ end
209
+ covered
210
+ end
211
+
212
+ def coverage_pct(covered_count, total_count)
213
+ return 100.0 if total_count.zero?
214
+
215
+ ((covered_count.to_f / total_count) * 100).round(1, half: :even)
216
+ end
217
+
218
+ def reverse_lex(row)
219
+ row.map(&:-@)
220
+ end
221
+ end
222
+ end