lattice-cli 0.1.0__py3-none-any.whl
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.
- lattice/__init__.py +5 -0
- lattice/__main__.py +5 -0
- lattice/agent.py +150 -0
- lattice/cli.py +488 -0
- lattice/constraints.py +391 -0
- lattice/formatters.py +131 -0
- lattice/ipog.py +308 -0
- lattice/model.py +116 -0
- lattice/parser.py +404 -0
- lattice/resources/claude/lattice-workflow/SKILL.md +109 -0
- lattice/resources/claude/lattice-workflow/references/modeling-rules.md +52 -0
- lattice/resources/claude/lattice-workflow/references/worked-examples.md +59 -0
- lattice/resources/codex/lattice-workflow/SKILL.md +118 -0
- lattice/resources/codex/lattice-workflow/agents/openai.yaml +7 -0
- lattice/resources/codex/lattice-workflow/references/modeling-rules.md +52 -0
- lattice/resources/codex/lattice-workflow/references/worked-examples.md +59 -0
- lattice/resources/generic/lattice-workflow/SKILL.md +124 -0
- lattice/resources/generic/lattice-workflow/references/modeling-rules.md +52 -0
- lattice/resources/generic/lattice-workflow/references/worked-examples.md +59 -0
- lattice_cli-0.1.0.dist-info/METADATA +229 -0
- lattice_cli-0.1.0.dist-info/RECORD +25 -0
- lattice_cli-0.1.0.dist-info/WHEEL +5 -0
- lattice_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lattice_cli-0.1.0.dist-info/licenses/LICENSE +176 -0
- lattice_cli-0.1.0.dist-info/top_level.txt +1 -0
lattice/constraints.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from itertools import product
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from lattice.model import (
|
|
9
|
+
Assignment,
|
|
10
|
+
BidirectionalConstraint,
|
|
11
|
+
ConditionalConstraint,
|
|
12
|
+
ForcedConstraint,
|
|
13
|
+
ForwardDependencyConstraint,
|
|
14
|
+
HigherOrderConstraint,
|
|
15
|
+
InvalidPairConstraint,
|
|
16
|
+
Model,
|
|
17
|
+
NA_VALUE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
try: # pragma: no cover - optional dependency
|
|
21
|
+
from ortools.sat.python import cp_model
|
|
22
|
+
except Exception: # pragma: no cover - optional dependency
|
|
23
|
+
cp_model = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
AssignmentRef = tuple[int, int]
|
|
27
|
+
PartialAssignment = dict[int, int]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConstraintError(ValueError):
|
|
31
|
+
"""Raised when compiled constraints are contradictory."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConstraintEngine:
|
|
35
|
+
def __init__(self, model: Model):
|
|
36
|
+
self.model = model
|
|
37
|
+
self.parameter_index = {parameter.name: index for index, parameter in enumerate(model.parameters)}
|
|
38
|
+
self.value_index = {
|
|
39
|
+
(parameter_index, value): value_index
|
|
40
|
+
for parameter_index, parameter in enumerate(model.parameters)
|
|
41
|
+
for value_index, value in enumerate(parameter.values)
|
|
42
|
+
}
|
|
43
|
+
self.invalid_pairs: dict[AssignmentRef, set[AssignmentRef]] = defaultdict(set)
|
|
44
|
+
self.requires: dict[AssignmentRef, set[AssignmentRef]] = defaultdict(set)
|
|
45
|
+
self.higher_order: list[tuple[tuple[AssignmentRef, ...], AssignmentRef]] = []
|
|
46
|
+
self.forced_interactions: list[tuple[AssignmentRef, ...]] = []
|
|
47
|
+
self._feasibility_cache: dict[tuple[tuple[int, int], ...], bool] = {}
|
|
48
|
+
self._compile_constraints()
|
|
49
|
+
self._compute_implied_pairs()
|
|
50
|
+
self._validate_compiled_constraints()
|
|
51
|
+
|
|
52
|
+
def conflicts(self, left: Assignment, right: Assignment) -> bool:
|
|
53
|
+
return self._to_ref(right) in self.invalid_pairs.get(self._to_ref(left), set())
|
|
54
|
+
|
|
55
|
+
def implied_requirements(self, assignment: Assignment) -> tuple[Assignment, ...]:
|
|
56
|
+
refs = self.requires.get(self._to_ref(assignment), set())
|
|
57
|
+
return tuple(sorted(self._from_ref(ref) for ref in refs))
|
|
58
|
+
|
|
59
|
+
def can_complete(self, partial: PartialAssignment) -> bool:
|
|
60
|
+
normalized = self._normalize_partial(dict(partial))
|
|
61
|
+
if normalized is None:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
signature = tuple(sorted(normalized.items()))
|
|
65
|
+
cached = self._feasibility_cache.get(signature)
|
|
66
|
+
if cached is not None:
|
|
67
|
+
return cached
|
|
68
|
+
|
|
69
|
+
if cp_model is not None:
|
|
70
|
+
result = self._can_complete_with_cp_sat(normalized)
|
|
71
|
+
else:
|
|
72
|
+
result = self._backtrack_complete(normalized, rng=None, failures=set()) is not None
|
|
73
|
+
|
|
74
|
+
self._feasibility_cache[signature] = result
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
def complete_assignment(self, partial: PartialAssignment, rng: random.Random | None = None) -> tuple[int, ...] | None:
|
|
78
|
+
normalized = self._normalize_partial(dict(partial))
|
|
79
|
+
if normalized is None:
|
|
80
|
+
return None
|
|
81
|
+
return self._backtrack_complete(normalized, rng=rng, failures=set())
|
|
82
|
+
|
|
83
|
+
def is_complete_assignment_valid(self, row: tuple[int, ...]) -> bool:
|
|
84
|
+
partial = {parameter_index: value_index for parameter_index, value_index in enumerate(row)}
|
|
85
|
+
normalized = self._normalize_partial(partial)
|
|
86
|
+
if normalized is None or len(normalized) != len(self.model.parameters):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
for antecedents, forbidden in self.higher_order:
|
|
90
|
+
if all(normalized.get(param_index) == value_index for param_index, value_index in antecedents):
|
|
91
|
+
if normalized.get(forbidden[0]) == forbidden[1]:
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def _compile_constraints(self) -> None:
|
|
96
|
+
for constraint in self.model.constraints:
|
|
97
|
+
if isinstance(constraint, InvalidPairConstraint):
|
|
98
|
+
self._add_invalid_pair(self._to_ref(constraint.if_assignment), self._to_ref(constraint.then_not))
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if isinstance(constraint, BidirectionalConstraint):
|
|
102
|
+
left, right = constraint.pair
|
|
103
|
+
left_ref = self._to_ref(left)
|
|
104
|
+
right_ref = self._to_ref(right)
|
|
105
|
+
self._add_requirement(left_ref, right_ref)
|
|
106
|
+
self._add_requirement(right_ref, left_ref)
|
|
107
|
+
self._exclude_other_values(left_ref, right_ref)
|
|
108
|
+
self._exclude_other_values(right_ref, left_ref)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if isinstance(constraint, ForwardDependencyConstraint):
|
|
112
|
+
source_ref = self._to_ref(constraint.source)
|
|
113
|
+
target_ref = self._to_ref(constraint.target)
|
|
114
|
+
self._add_requirement(source_ref, target_ref)
|
|
115
|
+
self._exclude_other_values(source_ref, target_ref)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if isinstance(constraint, ConditionalConstraint):
|
|
119
|
+
self._compile_conditional(constraint)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if isinstance(constraint, ForcedConstraint):
|
|
123
|
+
refs = tuple(self._to_ref(assignment) for assignment in constraint.assignments)
|
|
124
|
+
self.forced_interactions.append(refs)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if isinstance(constraint, HigherOrderConstraint):
|
|
128
|
+
antecedents = tuple(self._to_ref(assignment) for assignment in constraint.if_all)
|
|
129
|
+
forbidden = self._to_ref(constraint.then_not)
|
|
130
|
+
self.higher_order.append((antecedents, forbidden))
|
|
131
|
+
|
|
132
|
+
def _compile_conditional(self, constraint: ConditionalConstraint) -> None:
|
|
133
|
+
parent_index = self.parameter_index[constraint.parent]
|
|
134
|
+
child_index = self.parameter_index[constraint.parameter]
|
|
135
|
+
parent = self.model.parameters[parent_index]
|
|
136
|
+
child = self.model.parameters[child_index]
|
|
137
|
+
applicable_values = set(constraint.applies_when)
|
|
138
|
+
na_ref = (child_index, self.value_index[(child_index, NA_VALUE)])
|
|
139
|
+
|
|
140
|
+
for parent_value_index, parent_value in enumerate(parent.values):
|
|
141
|
+
parent_ref = (parent_index, parent_value_index)
|
|
142
|
+
if parent_value in applicable_values:
|
|
143
|
+
self._add_invalid_pair(parent_ref, na_ref)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
for child_value_index, child_value in enumerate(child.values):
|
|
147
|
+
if child_value == NA_VALUE:
|
|
148
|
+
continue
|
|
149
|
+
self._add_invalid_pair(parent_ref, (child_index, child_value_index))
|
|
150
|
+
|
|
151
|
+
def _compute_implied_pairs(self) -> None:
|
|
152
|
+
changed = True
|
|
153
|
+
all_assignments = list(self._all_assignment_refs())
|
|
154
|
+
while changed:
|
|
155
|
+
changed = False
|
|
156
|
+
|
|
157
|
+
for assignment in all_assignments:
|
|
158
|
+
implied = set(self.requires.get(assignment, set()))
|
|
159
|
+
for required in tuple(implied):
|
|
160
|
+
implied.update(self.requires.get(required, set()))
|
|
161
|
+
if implied - self.requires.get(assignment, set()):
|
|
162
|
+
self.requires[assignment].update(implied)
|
|
163
|
+
changed = True
|
|
164
|
+
|
|
165
|
+
for assignment in all_assignments:
|
|
166
|
+
additions: set[AssignmentRef] = set()
|
|
167
|
+
for conflicting in tuple(self.invalid_pairs.get(assignment, set())):
|
|
168
|
+
additions.update(self.requires.get(conflicting, set()))
|
|
169
|
+
for required in tuple(self.requires.get(assignment, set())):
|
|
170
|
+
additions.update(self.invalid_pairs.get(required, set()))
|
|
171
|
+
additions.discard(assignment)
|
|
172
|
+
for addition in additions:
|
|
173
|
+
if addition not in self.invalid_pairs.get(assignment, set()):
|
|
174
|
+
self._add_invalid_pair(assignment, addition)
|
|
175
|
+
changed = True
|
|
176
|
+
|
|
177
|
+
def _validate_compiled_constraints(self) -> None:
|
|
178
|
+
for assignment in self._all_assignment_refs():
|
|
179
|
+
if assignment in self.invalid_pairs.get(assignment, set()):
|
|
180
|
+
readable = self._from_ref(assignment)
|
|
181
|
+
raise ConstraintError(f"Constraint closure makes `{readable.parameter}={readable.value}` impossible.")
|
|
182
|
+
|
|
183
|
+
for required in self.requires.get(assignment, set()):
|
|
184
|
+
if assignment[0] == required[0] and assignment[1] != required[1]:
|
|
185
|
+
left = self._from_ref(assignment)
|
|
186
|
+
right = self._from_ref(required)
|
|
187
|
+
raise ConstraintError(
|
|
188
|
+
f"Constraint closure requires incompatible values `{left.parameter}={left.value}` and `{right.parameter}={right.value}`."
|
|
189
|
+
)
|
|
190
|
+
if required in self.invalid_pairs.get(assignment, set()):
|
|
191
|
+
left = self._from_ref(assignment)
|
|
192
|
+
right = self._from_ref(required)
|
|
193
|
+
raise ConstraintError(
|
|
194
|
+
f"Constraint closure makes `{left.parameter}={left.value}` conflict with its own requirement `{right.parameter}={right.value}`."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for forced in self.forced_interactions:
|
|
198
|
+
partial = {parameter_index: value_index for parameter_index, value_index in forced}
|
|
199
|
+
if self.complete_assignment(partial, rng=None) is None:
|
|
200
|
+
assignments = ", ".join(
|
|
201
|
+
f"{self.model.parameters[parameter_index].name}={self.model.parameters[parameter_index].values[value_index]}"
|
|
202
|
+
for parameter_index, value_index in forced
|
|
203
|
+
)
|
|
204
|
+
raise ConstraintError(f"Forced interaction is not feasible: {assignments}.")
|
|
205
|
+
|
|
206
|
+
def _normalize_partial(self, partial: PartialAssignment) -> PartialAssignment | None:
|
|
207
|
+
normalized = dict(partial)
|
|
208
|
+
changed = True
|
|
209
|
+
while changed:
|
|
210
|
+
changed = False
|
|
211
|
+
if not self._partial_consistent(normalized):
|
|
212
|
+
return None
|
|
213
|
+
for parameter_index, value_index in list(normalized.items()):
|
|
214
|
+
for required_parameter, required_value in self.requires.get((parameter_index, value_index), set()):
|
|
215
|
+
current = normalized.get(required_parameter)
|
|
216
|
+
if current is None:
|
|
217
|
+
normalized[required_parameter] = required_value
|
|
218
|
+
changed = True
|
|
219
|
+
continue
|
|
220
|
+
if current != required_value:
|
|
221
|
+
return None
|
|
222
|
+
if not self._partial_consistent(normalized):
|
|
223
|
+
return None
|
|
224
|
+
return normalized
|
|
225
|
+
|
|
226
|
+
def _partial_consistent(self, partial: PartialAssignment) -> bool:
|
|
227
|
+
for parameter_index, value_index in partial.items():
|
|
228
|
+
for other_parameter, other_value in partial.items():
|
|
229
|
+
if parameter_index == other_parameter and value_index != other_value:
|
|
230
|
+
return False
|
|
231
|
+
if parameter_index == other_parameter:
|
|
232
|
+
continue
|
|
233
|
+
if (other_parameter, other_value) in self.invalid_pairs.get((parameter_index, value_index), set()):
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
for antecedents, forbidden in self.higher_order:
|
|
237
|
+
if all(partial.get(parameter_index) == value_index for parameter_index, value_index in antecedents):
|
|
238
|
+
if partial.get(forbidden[0]) == forbidden[1]:
|
|
239
|
+
return False
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def _backtrack_complete(
|
|
243
|
+
self,
|
|
244
|
+
partial: PartialAssignment,
|
|
245
|
+
rng: random.Random | None,
|
|
246
|
+
failures: set[tuple[tuple[int, int], ...]],
|
|
247
|
+
) -> tuple[int, ...] | None:
|
|
248
|
+
signature = tuple(sorted(partial.items()))
|
|
249
|
+
if signature in failures:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
if len(partial) == len(self.model.parameters):
|
|
253
|
+
row = tuple(partial[index] for index in range(len(self.model.parameters)))
|
|
254
|
+
return row if self.is_complete_assignment_valid(row) else None
|
|
255
|
+
|
|
256
|
+
next_parameter, values = self._next_parameter_values(partial)
|
|
257
|
+
if next_parameter is None:
|
|
258
|
+
failures.add(signature)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
for value in self._ordered_values(next_parameter, values, rng):
|
|
262
|
+
candidate = dict(partial)
|
|
263
|
+
candidate[next_parameter] = value
|
|
264
|
+
normalized = self._normalize_partial(candidate)
|
|
265
|
+
if normalized is None:
|
|
266
|
+
continue
|
|
267
|
+
result = self._backtrack_complete(normalized, rng=rng, failures=failures)
|
|
268
|
+
if result is not None:
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
failures.add(signature)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def _next_parameter_values(self, partial: PartialAssignment) -> tuple[int | None, list[int]]:
|
|
275
|
+
best_parameter: int | None = None
|
|
276
|
+
best_values: list[int] = []
|
|
277
|
+
best_size: int | None = None
|
|
278
|
+
|
|
279
|
+
for parameter_index, parameter in enumerate(self.model.parameters):
|
|
280
|
+
if parameter_index in partial:
|
|
281
|
+
continue
|
|
282
|
+
feasible: list[int] = []
|
|
283
|
+
for value_index in range(len(parameter.values)):
|
|
284
|
+
candidate = dict(partial)
|
|
285
|
+
candidate[parameter_index] = value_index
|
|
286
|
+
if self._normalize_partial(candidate) is not None:
|
|
287
|
+
feasible.append(value_index)
|
|
288
|
+
|
|
289
|
+
if not feasible:
|
|
290
|
+
return None, []
|
|
291
|
+
|
|
292
|
+
if best_size is None or len(feasible) < best_size:
|
|
293
|
+
best_parameter = parameter_index
|
|
294
|
+
best_values = feasible
|
|
295
|
+
best_size = len(feasible)
|
|
296
|
+
|
|
297
|
+
return best_parameter, best_values
|
|
298
|
+
|
|
299
|
+
def _ordered_values(self, parameter_index: int, values: Iterable[int], rng: random.Random | None) -> list[int]:
|
|
300
|
+
parameter = self.model.parameters[parameter_index]
|
|
301
|
+
values_list = list(values)
|
|
302
|
+
if rng is None:
|
|
303
|
+
return sorted(values_list, key=lambda value_index: (-parameter.normalized_weights[value_index], value_index))
|
|
304
|
+
|
|
305
|
+
weighted = []
|
|
306
|
+
for value_index in values_list:
|
|
307
|
+
weight = parameter.normalized_weights[value_index]
|
|
308
|
+
ticket = rng.random() / max(weight, 1e-9)
|
|
309
|
+
weighted.append((ticket, value_index))
|
|
310
|
+
weighted.sort(key=lambda item: item[0])
|
|
311
|
+
return [value_index for _, value_index in weighted]
|
|
312
|
+
|
|
313
|
+
def _can_complete_with_cp_sat(self, partial: PartialAssignment) -> bool:
|
|
314
|
+
if cp_model is None: # pragma: no cover - guarded by caller
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
model = cp_model.CpModel()
|
|
318
|
+
variables = [
|
|
319
|
+
model.NewIntVar(0, len(parameter.values) - 1, parameter.name)
|
|
320
|
+
for parameter in self.model.parameters
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
for parameter_index, value_index in partial.items():
|
|
324
|
+
model.Add(variables[parameter_index] == value_index)
|
|
325
|
+
|
|
326
|
+
pair_constraints: dict[tuple[int, int], set[tuple[int, int]]] = defaultdict(set)
|
|
327
|
+
for left in self._all_assignment_refs():
|
|
328
|
+
for right in self.invalid_pairs.get(left, set()):
|
|
329
|
+
if left[0] >= right[0]:
|
|
330
|
+
continue
|
|
331
|
+
pair_constraints[(left[0], right[0])].add((left[1], right[1]))
|
|
332
|
+
|
|
333
|
+
for (left_index, right_index), forbidden_pairs in pair_constraints.items():
|
|
334
|
+
model.AddForbiddenAssignments(
|
|
335
|
+
[variables[left_index], variables[right_index]],
|
|
336
|
+
sorted(forbidden_pairs),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
literal_cache: dict[AssignmentRef, object] = {}
|
|
340
|
+
|
|
341
|
+
def literal_for(assignment: AssignmentRef):
|
|
342
|
+
cached = literal_cache.get(assignment)
|
|
343
|
+
if cached is not None:
|
|
344
|
+
return cached
|
|
345
|
+
lit = model.NewBoolVar(f"p{assignment[0]}_is_{assignment[1]}")
|
|
346
|
+
model.Add(variables[assignment[0]] == assignment[1]).OnlyEnforceIf(lit)
|
|
347
|
+
model.Add(variables[assignment[0]] != assignment[1]).OnlyEnforceIf(lit.Not())
|
|
348
|
+
literal_cache[assignment] = lit
|
|
349
|
+
return lit
|
|
350
|
+
|
|
351
|
+
for antecedents, forbidden in self.higher_order:
|
|
352
|
+
model.Add(variables[forbidden[0]] != forbidden[1]).OnlyEnforceIf(
|
|
353
|
+
[literal_for(assignment) for assignment in antecedents]
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
model.AddDecisionStrategy(variables, cp_model.CHOOSE_FIRST, cp_model.SELECT_MIN_VALUE)
|
|
357
|
+
|
|
358
|
+
solver = cp_model.CpSolver()
|
|
359
|
+
solver.parameters.num_search_workers = 1
|
|
360
|
+
solver.parameters.random_seed = 0
|
|
361
|
+
solver.parameters.max_time_in_seconds = 2.0
|
|
362
|
+
status = solver.Solve(model)
|
|
363
|
+
return status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
|
|
364
|
+
|
|
365
|
+
def _exclude_other_values(self, source: AssignmentRef, required: AssignmentRef) -> None:
|
|
366
|
+
target_parameter = self.model.parameters[required[0]]
|
|
367
|
+
for value_index in range(len(target_parameter.values)):
|
|
368
|
+
if value_index == required[1]:
|
|
369
|
+
continue
|
|
370
|
+
self._add_invalid_pair(source, (required[0], value_index))
|
|
371
|
+
|
|
372
|
+
def _add_invalid_pair(self, left: AssignmentRef, right: AssignmentRef) -> None:
|
|
373
|
+
self.invalid_pairs[left].add(right)
|
|
374
|
+
self.invalid_pairs[right].add(left)
|
|
375
|
+
|
|
376
|
+
def _add_requirement(self, source: AssignmentRef, target: AssignmentRef) -> None:
|
|
377
|
+
self.requires[source].add(target)
|
|
378
|
+
|
|
379
|
+
def _all_assignment_refs(self) -> Iterable[AssignmentRef]:
|
|
380
|
+
for parameter_index, parameter in enumerate(self.model.parameters):
|
|
381
|
+
for value_index in range(len(parameter.values)):
|
|
382
|
+
yield (parameter_index, value_index)
|
|
383
|
+
|
|
384
|
+
def _to_ref(self, assignment: Assignment) -> AssignmentRef:
|
|
385
|
+
parameter_index = self.parameter_index[assignment.parameter]
|
|
386
|
+
value_index = self.value_index[(parameter_index, assignment.value)]
|
|
387
|
+
return (parameter_index, value_index)
|
|
388
|
+
|
|
389
|
+
def _from_ref(self, assignment: AssignmentRef) -> Assignment:
|
|
390
|
+
parameter = self.model.parameters[assignment[0]]
|
|
391
|
+
return Assignment(parameter=parameter.name, value=parameter.values[assignment[1]])
|
lattice/formatters.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from lattice.ipog import GenerationResult
|
|
8
|
+
from lattice.model import Model
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_output(model: Model, result: GenerationResult, *, output_format: str, seed: int) -> str:
|
|
12
|
+
if output_format == "table":
|
|
13
|
+
return format_table(model, result)
|
|
14
|
+
if output_format == "json":
|
|
15
|
+
return format_json(model, result, seed=seed)
|
|
16
|
+
if output_format == "csv":
|
|
17
|
+
return format_csv(model, result)
|
|
18
|
+
if output_format == "summary":
|
|
19
|
+
return format_summary(model, result)
|
|
20
|
+
raise ValueError(f"Unknown format `{output_format}`.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_table(model: Model, result: GenerationResult) -> str:
|
|
24
|
+
rows = _named_rows(model, result)
|
|
25
|
+
headers = ["#"] + list(model.parameter_names)
|
|
26
|
+
body = [[str(index + 1), *[row[name] for name in model.parameter_names]] for index, row in enumerate(rows)]
|
|
27
|
+
widths = [
|
|
28
|
+
max(len(headers[column]), *(len(record[column]) for record in body)) if body else len(headers[column])
|
|
29
|
+
for column in range(len(headers))
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
lines = [_headline(model, result), ""]
|
|
33
|
+
lines.extend(_milestone_lines(result))
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append(
|
|
36
|
+
" | ".join(
|
|
37
|
+
headers[column].rjust(widths[column]) if column == 0 else headers[column].ljust(widths[column])
|
|
38
|
+
for column in range(len(headers))
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
lines.append("-|-".join("-" * width for width in widths))
|
|
42
|
+
|
|
43
|
+
for record in body:
|
|
44
|
+
lines.append(
|
|
45
|
+
" | ".join(
|
|
46
|
+
record[column].rjust(widths[column]) if column == 0 else record[column].ljust(widths[column])
|
|
47
|
+
for column in range(len(record))
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return "\n".join(lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_summary(model: Model, result: GenerationResult) -> str:
|
|
54
|
+
lines = [_headline(model, result), ""]
|
|
55
|
+
lines.extend(_milestone_lines(result))
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_json(model: Model, result: GenerationResult, *, seed: int) -> str:
|
|
60
|
+
payload = {
|
|
61
|
+
"meta": {
|
|
62
|
+
"model_name": model.model_name,
|
|
63
|
+
"strength": model.strength,
|
|
64
|
+
"test_count": len(result.rows),
|
|
65
|
+
"exhaustive_count": model.exhaustive_count,
|
|
66
|
+
"reduction": _reduction_label(model.exhaustive_count, len(result.rows)),
|
|
67
|
+
"coverage": f"{result.coverage_curve[-1].cumulative_pct:.1f}%" if result.coverage_curve else "0.0%",
|
|
68
|
+
"seed": seed,
|
|
69
|
+
},
|
|
70
|
+
"coverage_curve": [
|
|
71
|
+
{
|
|
72
|
+
"scenario": point.scenario,
|
|
73
|
+
"cumulative_pct": point.cumulative_pct,
|
|
74
|
+
}
|
|
75
|
+
for point in result.coverage_curve
|
|
76
|
+
],
|
|
77
|
+
"scenarios": [
|
|
78
|
+
{
|
|
79
|
+
"id": index + 1,
|
|
80
|
+
"values": {name: row[name] for name in model.parameter_names},
|
|
81
|
+
}
|
|
82
|
+
for index, row in enumerate(_named_rows(model, result))
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
return json.dumps(payload, indent=2)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_csv(model: Model, result: GenerationResult) -> str:
|
|
89
|
+
buffer = io.StringIO()
|
|
90
|
+
writer = csv.writer(buffer)
|
|
91
|
+
writer.writerow(model.parameter_names)
|
|
92
|
+
for row in _named_rows(model, result):
|
|
93
|
+
writer.writerow([row[name] for name in model.parameter_names])
|
|
94
|
+
return buffer.getvalue().strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _headline(model: Model, result: GenerationResult) -> str:
|
|
98
|
+
return f"Lattice — {len(result.rows)} scenarios (from {model.exhaustive_count:,} possible)"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _milestone_lines(result: GenerationResult) -> list[str]:
|
|
102
|
+
milestones: list[str] = []
|
|
103
|
+
for threshold in (50.0, 75.0, 90.0, 100.0):
|
|
104
|
+
scenario = next(
|
|
105
|
+
(point.scenario for point in result.coverage_curve if point.cumulative_pct >= threshold),
|
|
106
|
+
None,
|
|
107
|
+
)
|
|
108
|
+
if scenario is not None:
|
|
109
|
+
milestones.append(f" {int(threshold)}% coverage at scenario #{scenario}")
|
|
110
|
+
return milestones
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _named_rows(model: Model, result: GenerationResult) -> list[dict[str, str]]:
|
|
114
|
+
named_rows: list[dict[str, str]] = []
|
|
115
|
+
for row in result.rows:
|
|
116
|
+
named_rows.append(
|
|
117
|
+
{
|
|
118
|
+
parameter.name: parameter.values[row[index]]
|
|
119
|
+
for index, parameter in enumerate(model.parameters)
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
return named_rows
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _reduction_label(exhaustive_count: int, scenario_count: int) -> str:
|
|
126
|
+
if scenario_count == 0:
|
|
127
|
+
return "inf"
|
|
128
|
+
reduction = exhaustive_count / scenario_count
|
|
129
|
+
if abs(reduction - round(reduction)) < 1e-9:
|
|
130
|
+
return f"{int(round(reduction))}x"
|
|
131
|
+
return f"{reduction:.1f}x"
|