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.
- checksums.yaml +7 -0
- data/LICENSE +176 -0
- data/README.md +121 -0
- data/exe/lattice +7 -0
- data/lib/lattice/agent.rb +153 -0
- data/lib/lattice/cli.rb +469 -0
- data/lib/lattice/constraints.rb +357 -0
- data/lib/lattice/formatters.rb +126 -0
- data/lib/lattice/ipog.rb +222 -0
- data/lib/lattice/mersenne_twister.rb +102 -0
- data/lib/lattice/model.rb +83 -0
- data/lib/lattice/parser.rb +387 -0
- data/lib/lattice/resources/claude/lattice-workflow/SKILL.md +142 -0
- data/lib/lattice/resources/claude/lattice-workflow/references/modeling-rules.md +52 -0
- data/lib/lattice/resources/claude/lattice-workflow/references/worked-examples.md +59 -0
- data/lib/lattice/resources/codex/lattice-workflow/SKILL.md +142 -0
- data/lib/lattice/resources/codex/lattice-workflow/agents/openai.yaml +7 -0
- data/lib/lattice/resources/codex/lattice-workflow/references/modeling-rules.md +52 -0
- data/lib/lattice/resources/codex/lattice-workflow/references/worked-examples.md +59 -0
- data/lib/lattice/resources/generic/lattice-workflow/SKILL.md +150 -0
- data/lib/lattice/resources/generic/lattice-workflow/references/modeling-rules.md +52 -0
- data/lib/lattice/resources/generic/lattice-workflow/references/worked-examples.md +59 -0
- data/lib/lattice/version.rb +5 -0
- data/lib/lattice.rb +87 -0
- data/lib/latticerb.rb +4 -0
- metadata +86 -0
|
@@ -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
|
data/lib/lattice/ipog.rb
ADDED
|
@@ -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
|