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/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"