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/ipog.py ADDED
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from itertools import combinations, product
7
+
8
+ from lattice.constraints import ConstraintEngine
9
+ from lattice.model import Model
10
+
11
+
12
+ TupleKey = tuple[tuple[int, int], ...]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CoveragePoint:
17
+ scenario: int
18
+ cumulative_pct: float
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class GenerationResult:
23
+ rows: tuple[tuple[int, ...], ...]
24
+ coverage_curve: tuple[CoveragePoint, ...]
25
+ total_valid_tuples: int
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class GenerationProgress:
30
+ event: str
31
+ scenario: int
32
+ covered: int
33
+ total: int
34
+ cumulative_pct: float
35
+ uncovered: int
36
+
37
+
38
+ ProgressCallback = Callable[[GenerationProgress], None]
39
+
40
+
41
+ def generate_covering_array(
42
+ model: Model,
43
+ *,
44
+ strength: int | None = None,
45
+ seed: int = 42,
46
+ pool_size: int = 50,
47
+ progress: ProgressCallback | None = None,
48
+ max_rows: int | None = None,
49
+ stop_after_coverage: float | None = None,
50
+ ) -> GenerationResult:
51
+ actual_strength = strength if strength is not None else model.strength
52
+ if pool_size < 1:
53
+ raise ValueError("`pool_size` must be at least 1.")
54
+ if max_rows is not None and max_rows < 1:
55
+ raise ValueError("`max_rows` must be at least 1.")
56
+ coverage_target = _coverage_target(stop_after_coverage)
57
+
58
+ engine = ConstraintEngine(model)
59
+ if not engine.can_complete({}):
60
+ raise ValueError("Model has no valid full assignments.")
61
+
62
+ valid_tuples = enumerate_valid_tuples(model, engine, actual_strength, progress=progress)
63
+ uncovered = set(valid_tuples)
64
+ rows: list[tuple[int, ...]] = []
65
+ coverage_points: list[CoveragePoint] = []
66
+ rng = random.Random(seed)
67
+ limit_reached = False
68
+ if progress is not None:
69
+ progress(
70
+ GenerationProgress(
71
+ event="enumerated",
72
+ scenario=0,
73
+ covered=0,
74
+ total=len(valid_tuples),
75
+ cumulative_pct=_coverage_pct(0, len(valid_tuples)),
76
+ uncovered=len(uncovered),
77
+ )
78
+ )
79
+
80
+ for forced in engine.forced_interactions:
81
+ partial = {parameter_index: value_index for parameter_index, value_index in forced}
82
+ row = _best_candidate_row(model, engine, partial, uncovered, actual_strength, pool_size, rng)
83
+ if row is None or row in rows:
84
+ continue
85
+ newly_covered = _covered_tuples(row, actual_strength, uncovered)
86
+ rows.append(row)
87
+ uncovered.difference_update(newly_covered)
88
+ covered = len(valid_tuples) - len(uncovered)
89
+ cumulative_pct = _coverage_pct(covered, len(valid_tuples))
90
+ coverage_points.append(
91
+ CoveragePoint(
92
+ scenario=len(rows),
93
+ cumulative_pct=cumulative_pct,
94
+ )
95
+ )
96
+ if progress is not None:
97
+ progress(
98
+ GenerationProgress(
99
+ event="scenario",
100
+ scenario=len(rows),
101
+ covered=covered,
102
+ total=len(valid_tuples),
103
+ cumulative_pct=cumulative_pct,
104
+ uncovered=len(uncovered),
105
+ )
106
+ )
107
+ if _should_stop(len(rows), cumulative_pct, max_rows, coverage_target):
108
+ limit_reached = True
109
+ if progress is not None:
110
+ progress(
111
+ GenerationProgress(
112
+ event="stopped",
113
+ scenario=len(rows),
114
+ covered=covered,
115
+ total=len(valid_tuples),
116
+ cumulative_pct=cumulative_pct,
117
+ uncovered=len(uncovered),
118
+ )
119
+ )
120
+ break
121
+
122
+ while uncovered and not limit_reached:
123
+ seed_tuple = min(uncovered)
124
+ partial = {parameter_index: value_index for parameter_index, value_index in seed_tuple}
125
+ row = _best_candidate_row(model, engine, partial, uncovered, actual_strength, pool_size, rng)
126
+ if row is None:
127
+ raise RuntimeError(f"Unable to complete uncovered tuple: {seed_tuple!r}")
128
+
129
+ newly_covered = _covered_tuples(row, actual_strength, uncovered)
130
+ rows.append(row)
131
+ uncovered.difference_update(newly_covered)
132
+ covered = len(valid_tuples) - len(uncovered)
133
+ cumulative_pct = _coverage_pct(covered, len(valid_tuples))
134
+ coverage_points.append(
135
+ CoveragePoint(
136
+ scenario=len(rows),
137
+ cumulative_pct=cumulative_pct,
138
+ )
139
+ )
140
+ if progress is not None:
141
+ progress(
142
+ GenerationProgress(
143
+ event="scenario",
144
+ scenario=len(rows),
145
+ covered=covered,
146
+ total=len(valid_tuples),
147
+ cumulative_pct=cumulative_pct,
148
+ uncovered=len(uncovered),
149
+ )
150
+ )
151
+ if _should_stop(len(rows), cumulative_pct, max_rows, coverage_target):
152
+ limit_reached = True
153
+ if progress is not None:
154
+ progress(
155
+ GenerationProgress(
156
+ event="stopped",
157
+ scenario=len(rows),
158
+ covered=covered,
159
+ total=len(valid_tuples),
160
+ cumulative_pct=cumulative_pct,
161
+ uncovered=len(uncovered),
162
+ )
163
+ )
164
+ break
165
+
166
+ return GenerationResult(
167
+ rows=tuple(rows),
168
+ coverage_curve=tuple(coverage_points),
169
+ total_valid_tuples=len(valid_tuples),
170
+ )
171
+
172
+
173
+ def enumerate_valid_tuples(
174
+ model: Model,
175
+ engine: ConstraintEngine,
176
+ strength: int,
177
+ *,
178
+ progress: ProgressCallback | None = None,
179
+ ) -> tuple[TupleKey, ...]:
180
+ tuples: list[TupleKey] = []
181
+ total_candidates = _candidate_tuple_count(model, strength)
182
+ checked = 0
183
+ for parameter_combo in combinations(range(len(model.parameters)), strength):
184
+ domains = [range(len(model.parameters[parameter_index].values)) for parameter_index in parameter_combo]
185
+ for value_combo in product(*domains):
186
+ checked += 1
187
+ partial = {parameter_combo[offset]: value_combo[offset] for offset in range(strength)}
188
+ if engine.can_complete(partial):
189
+ tuples.append(tuple(zip(parameter_combo, value_combo)))
190
+ if progress is not None and checked % 1000 == 0:
191
+ progress(
192
+ GenerationProgress(
193
+ event="enumerating",
194
+ scenario=0,
195
+ covered=checked,
196
+ total=total_candidates,
197
+ cumulative_pct=_coverage_pct(checked, total_candidates),
198
+ uncovered=max(0, total_candidates - checked),
199
+ )
200
+ )
201
+ if progress is not None:
202
+ progress(
203
+ GenerationProgress(
204
+ event="enumerating",
205
+ scenario=0,
206
+ covered=checked,
207
+ total=total_candidates,
208
+ cumulative_pct=_coverage_pct(checked, total_candidates),
209
+ uncovered=max(0, total_candidates - checked),
210
+ )
211
+ )
212
+ return tuple(tuples)
213
+
214
+
215
+ def _candidate_tuple_count(model: Model, strength: int) -> int:
216
+ total = 0
217
+ for parameter_combo in combinations(range(len(model.parameters)), strength):
218
+ count = 1
219
+ for parameter_index in parameter_combo:
220
+ count *= len(model.parameters[parameter_index].values)
221
+ total += count
222
+ return total
223
+
224
+
225
+ def _coverage_target(stop_after_coverage: float | None) -> float | None:
226
+ if stop_after_coverage is None:
227
+ return None
228
+ if not 0 < stop_after_coverage <= 100:
229
+ raise ValueError("`stop_after_coverage` must be in the range (0, 100].")
230
+ if stop_after_coverage >= 100:
231
+ return None
232
+ return stop_after_coverage
233
+
234
+
235
+ def _should_stop(
236
+ row_count: int,
237
+ cumulative_pct: float,
238
+ max_rows: int | None,
239
+ coverage_target: float | None,
240
+ ) -> bool:
241
+ if max_rows is not None and row_count >= max_rows:
242
+ return True
243
+ if coverage_target is not None and cumulative_pct >= coverage_target:
244
+ return True
245
+ return False
246
+
247
+
248
+ def _best_candidate_row(
249
+ model: Model,
250
+ engine: ConstraintEngine,
251
+ partial: dict[int, int],
252
+ uncovered: set[TupleKey],
253
+ strength: int,
254
+ pool_size: int,
255
+ rng: random.Random,
256
+ ) -> tuple[int, ...] | None:
257
+ candidates: dict[tuple[int, ...], tuple[int, float]] = {}
258
+ deterministic = engine.complete_assignment(partial, rng=None)
259
+ if deterministic is not None:
260
+ candidates[deterministic] = _score_row(model, deterministic, uncovered, strength)
261
+
262
+ for _ in range(pool_size - 1):
263
+ attempt_rng = random.Random(rng.getrandbits(64))
264
+ candidate = engine.complete_assignment(partial, rng=attempt_rng)
265
+ if candidate is None:
266
+ continue
267
+ score = _score_row(model, candidate, uncovered, strength)
268
+ previous = candidates.get(candidate)
269
+ if previous is None or score > previous:
270
+ candidates[candidate] = score
271
+
272
+ if not candidates:
273
+ return None
274
+
275
+ return max(candidates.items(), key=lambda item: (item[1][0], item[1][1], _reverse_lex(item[0])))[0]
276
+
277
+
278
+ def _score_row(
279
+ model: Model,
280
+ row: tuple[int, ...],
281
+ uncovered: set[TupleKey],
282
+ strength: int,
283
+ ) -> tuple[int, float]:
284
+ newly_covered = len(_covered_tuples(row, strength, uncovered))
285
+ weight_score = sum(
286
+ model.parameters[parameter_index].normalized_weights[value_index]
287
+ for parameter_index, value_index in enumerate(row)
288
+ )
289
+ return newly_covered, weight_score
290
+
291
+
292
+ def _covered_tuples(row: tuple[int, ...], strength: int, candidate_tuples: set[TupleKey]) -> set[TupleKey]:
293
+ covered: set[TupleKey] = set()
294
+ for parameter_combo in combinations(range(len(row)), strength):
295
+ tuple_key = tuple((parameter_index, row[parameter_index]) for parameter_index in parameter_combo)
296
+ if tuple_key in candidate_tuples:
297
+ covered.add(tuple_key)
298
+ return covered
299
+
300
+
301
+ def _coverage_pct(covered_count: int, total_count: int) -> float:
302
+ if total_count == 0:
303
+ return 100.0
304
+ return round((covered_count / total_count) * 100, 1)
305
+
306
+
307
+ def _reverse_lex(row: tuple[int, ...]) -> tuple[int, ...]:
308
+ return tuple(-value for value in row)
lattice/model.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import cached_property
5
+ from typing import Any, TypeAlias
6
+
7
+
8
+ NormalizedValue: TypeAlias = str
9
+ NA_VALUE = "N/A"
10
+
11
+
12
+ def normalize_scalar(value: Any) -> NormalizedValue:
13
+ if isinstance(value, bool):
14
+ return "true" if value else "false"
15
+ if value is None:
16
+ return "null"
17
+ return str(value)
18
+
19
+
20
+ @dataclass(frozen=True, order=True)
21
+ class Assignment:
22
+ parameter: str
23
+ value: NormalizedValue
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Parameter:
28
+ name: str
29
+ values: tuple[NormalizedValue, ...]
30
+ weights: tuple[float, ...] | None = None
31
+
32
+ @cached_property
33
+ def value_set(self) -> frozenset[NormalizedValue]:
34
+ return frozenset(self.values)
35
+
36
+ @cached_property
37
+ def normalized_weights(self) -> tuple[float, ...]:
38
+ if self.weights is None:
39
+ uniform = 1.0 / len(self.values)
40
+ return tuple(uniform for _ in self.values)
41
+
42
+ total = sum(self.weights)
43
+ if total <= 0:
44
+ uniform = 1.0 / len(self.values)
45
+ return tuple(uniform for _ in self.values)
46
+
47
+ return tuple(weight / total for weight in self.weights)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class InvalidPairConstraint:
52
+ if_assignment: Assignment
53
+ then_not: Assignment
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class BidirectionalConstraint:
58
+ pair: tuple[Assignment, Assignment]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ForwardDependencyConstraint:
63
+ source: Assignment
64
+ target: Assignment
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ConditionalConstraint:
69
+ parameter: str
70
+ values: tuple[NormalizedValue, ...]
71
+ parent: str
72
+ applies_when: tuple[NormalizedValue, ...]
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ForcedConstraint:
77
+ assignments: tuple[Assignment, ...]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class HigherOrderConstraint:
82
+ if_all: tuple[Assignment, ...]
83
+ then_not: Assignment
84
+
85
+
86
+ Constraint = (
87
+ InvalidPairConstraint
88
+ | BidirectionalConstraint
89
+ | ForwardDependencyConstraint
90
+ | ConditionalConstraint
91
+ | ForcedConstraint
92
+ | HigherOrderConstraint
93
+ )
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Model:
98
+ model_name: str
99
+ parameters: tuple[Parameter, ...]
100
+ constraints: tuple[Constraint, ...]
101
+ strength: int = 2
102
+
103
+ @cached_property
104
+ def parameter_names(self) -> tuple[str, ...]:
105
+ return tuple(parameter.name for parameter in self.parameters)
106
+
107
+ @cached_property
108
+ def parameter_lookup(self) -> dict[str, Parameter]:
109
+ return {parameter.name: parameter for parameter in self.parameters}
110
+
111
+ @cached_property
112
+ def exhaustive_count(self) -> int:
113
+ total = 1
114
+ for parameter in self.parameters:
115
+ total *= len(parameter.values)
116
+ return total