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