latticerb 0.3.1 → 0.4.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 +4 -4
- data/Cargo.lock +288 -0
- data/Cargo.toml +7 -0
- data/README.md +34 -4
- data/ext/lattice_native/Cargo.toml +13 -0
- data/ext/lattice_native/extconf.rb +6 -0
- data/ext/lattice_native/rustfmt.toml +1 -0
- data/ext/lattice_native/src/lib.rs +114 -0
- data/lib/lattice/cli.rb +3 -3
- data/lib/lattice/constraints.rb +60 -152
- data/lib/lattice/formatters.rb +1 -1
- data/lib/lattice/ipog.rb +47 -166
- data/lib/lattice/native.rb +10 -0
- data/lib/lattice/version.rb +1 -1
- data/lib/lattice.rb +2 -2
- metadata +27 -5
- data/lib/lattice/mersenne_twister.rb +0 -102
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8b8ff092e386afd2f650b4b9915d837bab09cae92817516403ec373beba6997
|
|
4
|
+
data.tar.gz: 4b68178ac100e814c2613a3bd8ec724a60701fb5ae36a3f93038743f167a4b1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a33e59f0410f46973d5282ed63113567c4aa0c83f5f337436abc65cd37efe300b8cd30a8d3569c296cc3690e9d71d15fe847b0e58a880e039b992e342889aef
|
|
7
|
+
data.tar.gz: 3338d9ee6b71c1f8b5acac50b68139be4570da152efc58994a4ed73f33c730ccf649e6492b6fb522a7509b9c65ed8f46be45b7fe069e1b5485540f89bb86e522
|
data/Cargo.lock
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "aho-corasick"
|
|
7
|
+
version = "1.1.5"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"memchr",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "bindgen"
|
|
16
|
+
version = "0.72.1"
|
|
17
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
18
|
+
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"bitflags",
|
|
21
|
+
"cexpr",
|
|
22
|
+
"clang-sys",
|
|
23
|
+
"itertools",
|
|
24
|
+
"proc-macro2",
|
|
25
|
+
"quote",
|
|
26
|
+
"regex",
|
|
27
|
+
"rustc-hash",
|
|
28
|
+
"shlex",
|
|
29
|
+
"syn",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[package]]
|
|
33
|
+
name = "bitflags"
|
|
34
|
+
version = "2.13.2"
|
|
35
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
36
|
+
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
|
37
|
+
|
|
38
|
+
[[package]]
|
|
39
|
+
name = "cexpr"
|
|
40
|
+
version = "0.6.0"
|
|
41
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
42
|
+
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
|
43
|
+
dependencies = [
|
|
44
|
+
"nom",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[[package]]
|
|
48
|
+
name = "cfg-if"
|
|
49
|
+
version = "1.0.5"
|
|
50
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
51
|
+
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
|
52
|
+
|
|
53
|
+
[[package]]
|
|
54
|
+
name = "clang-sys"
|
|
55
|
+
version = "1.9.1"
|
|
56
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
57
|
+
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
|
58
|
+
dependencies = [
|
|
59
|
+
"glob",
|
|
60
|
+
"libc",
|
|
61
|
+
"libloading",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[[package]]
|
|
65
|
+
name = "either"
|
|
66
|
+
version = "1.19.0"
|
|
67
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
68
|
+
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|
69
|
+
|
|
70
|
+
[[package]]
|
|
71
|
+
name = "glob"
|
|
72
|
+
version = "0.3.4"
|
|
73
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
74
|
+
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
|
75
|
+
|
|
76
|
+
[[package]]
|
|
77
|
+
name = "itertools"
|
|
78
|
+
version = "0.13.0"
|
|
79
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
80
|
+
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
|
81
|
+
dependencies = [
|
|
82
|
+
"either",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[[package]]
|
|
86
|
+
name = "lattice-engine"
|
|
87
|
+
version = "0.1.0"
|
|
88
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
89
|
+
checksum = "b686b7e557fb3f0246835e631958e8faaa44687e9afc2356ccd85438a952be92"
|
|
90
|
+
|
|
91
|
+
[[package]]
|
|
92
|
+
name = "lattice_native"
|
|
93
|
+
version = "0.4.0"
|
|
94
|
+
dependencies = [
|
|
95
|
+
"lattice-engine",
|
|
96
|
+
"magnus",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[[package]]
|
|
100
|
+
name = "lazy_static"
|
|
101
|
+
version = "1.5.1"
|
|
102
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
103
|
+
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
|
104
|
+
|
|
105
|
+
[[package]]
|
|
106
|
+
name = "libc"
|
|
107
|
+
version = "0.2.191"
|
|
108
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
109
|
+
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
|
110
|
+
|
|
111
|
+
[[package]]
|
|
112
|
+
name = "libloading"
|
|
113
|
+
version = "0.8.9"
|
|
114
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
115
|
+
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
|
116
|
+
dependencies = [
|
|
117
|
+
"cfg-if",
|
|
118
|
+
"windows-link",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
[[package]]
|
|
122
|
+
name = "magnus"
|
|
123
|
+
version = "0.9.3"
|
|
124
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
125
|
+
checksum = "40ef424404d166cbb71c8b8d19c9c589c748c4547c1afa1928c5504299f6c713"
|
|
126
|
+
dependencies = [
|
|
127
|
+
"magnus-macros",
|
|
128
|
+
"rb-sys",
|
|
129
|
+
"rb-sys-env",
|
|
130
|
+
"seq-macro",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
[[package]]
|
|
134
|
+
name = "magnus-macros"
|
|
135
|
+
version = "0.9.1"
|
|
136
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
137
|
+
checksum = "9dafde2eeb66f090e0f326112323eb8324b12cb32b5d1210e541d3df6b0ce77e"
|
|
138
|
+
dependencies = [
|
|
139
|
+
"proc-macro2",
|
|
140
|
+
"quote",
|
|
141
|
+
"syn",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
[[package]]
|
|
145
|
+
name = "memchr"
|
|
146
|
+
version = "2.8.3"
|
|
147
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
148
|
+
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
149
|
+
|
|
150
|
+
[[package]]
|
|
151
|
+
name = "minimal-lexical"
|
|
152
|
+
version = "0.2.1"
|
|
153
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
154
|
+
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|
155
|
+
|
|
156
|
+
[[package]]
|
|
157
|
+
name = "nom"
|
|
158
|
+
version = "7.1.3"
|
|
159
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
160
|
+
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
|
161
|
+
dependencies = [
|
|
162
|
+
"memchr",
|
|
163
|
+
"minimal-lexical",
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
[[package]]
|
|
167
|
+
name = "proc-macro2"
|
|
168
|
+
version = "1.0.107"
|
|
169
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
170
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
171
|
+
dependencies = [
|
|
172
|
+
"unicode-ident",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
[[package]]
|
|
176
|
+
name = "quote"
|
|
177
|
+
version = "1.0.47"
|
|
178
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
179
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
180
|
+
dependencies = [
|
|
181
|
+
"proc-macro2",
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
[[package]]
|
|
185
|
+
name = "rb-sys"
|
|
186
|
+
version = "0.9.130"
|
|
187
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
188
|
+
checksum = "02faf625bb10ba893e3ae620f19c9fb1b5f8fcae0fe4eb86bb3f2230fad75edb"
|
|
189
|
+
dependencies = [
|
|
190
|
+
"rb-sys-build",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
[[package]]
|
|
194
|
+
name = "rb-sys-build"
|
|
195
|
+
version = "0.9.130"
|
|
196
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
197
|
+
checksum = "05be6f9c86fe5482808162826f6c047d093b3670582296c770de1d94f8066694"
|
|
198
|
+
dependencies = [
|
|
199
|
+
"bindgen",
|
|
200
|
+
"lazy_static",
|
|
201
|
+
"proc-macro2",
|
|
202
|
+
"quote",
|
|
203
|
+
"regex",
|
|
204
|
+
"shell-words",
|
|
205
|
+
"syn",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
[[package]]
|
|
209
|
+
name = "rb-sys-env"
|
|
210
|
+
version = "0.2.3"
|
|
211
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
212
|
+
checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a"
|
|
213
|
+
|
|
214
|
+
[[package]]
|
|
215
|
+
name = "regex"
|
|
216
|
+
version = "1.13.1"
|
|
217
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
218
|
+
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
|
219
|
+
dependencies = [
|
|
220
|
+
"aho-corasick",
|
|
221
|
+
"memchr",
|
|
222
|
+
"regex-automata",
|
|
223
|
+
"regex-syntax",
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
[[package]]
|
|
227
|
+
name = "regex-automata"
|
|
228
|
+
version = "0.4.18"
|
|
229
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
230
|
+
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
|
231
|
+
dependencies = [
|
|
232
|
+
"aho-corasick",
|
|
233
|
+
"memchr",
|
|
234
|
+
"regex-syntax",
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
[[package]]
|
|
238
|
+
name = "regex-syntax"
|
|
239
|
+
version = "0.8.11"
|
|
240
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
241
|
+
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|
242
|
+
|
|
243
|
+
[[package]]
|
|
244
|
+
name = "rustc-hash"
|
|
245
|
+
version = "2.1.3"
|
|
246
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
247
|
+
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
248
|
+
|
|
249
|
+
[[package]]
|
|
250
|
+
name = "seq-macro"
|
|
251
|
+
version = "0.3.6"
|
|
252
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
253
|
+
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
|
|
254
|
+
|
|
255
|
+
[[package]]
|
|
256
|
+
name = "shell-words"
|
|
257
|
+
version = "1.1.1"
|
|
258
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
259
|
+
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
|
260
|
+
|
|
261
|
+
[[package]]
|
|
262
|
+
name = "shlex"
|
|
263
|
+
version = "1.3.0"
|
|
264
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
265
|
+
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
|
266
|
+
|
|
267
|
+
[[package]]
|
|
268
|
+
name = "syn"
|
|
269
|
+
version = "2.0.119"
|
|
270
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
271
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
272
|
+
dependencies = [
|
|
273
|
+
"proc-macro2",
|
|
274
|
+
"quote",
|
|
275
|
+
"unicode-ident",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
[[package]]
|
|
279
|
+
name = "unicode-ident"
|
|
280
|
+
version = "1.0.27"
|
|
281
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
282
|
+
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
|
283
|
+
|
|
284
|
+
[[package]]
|
|
285
|
+
name = "windows-link"
|
|
286
|
+
version = "0.2.1"
|
|
287
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
288
|
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
data/Cargo.toml
ADDED
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Deterministic combinatorial coverage for constrained variant surfaces — roles,
|
|
|
4
4
|
|
|
5
5
|
Lattice takes a schema of parameters, values, and constraints and generates a minimal covering array: the smallest practical set of scenarios in which every valid pair (or triple, at strength 3) of values appears at least once. Constraints are honored during generation, so no scenario is ever invalid.
|
|
6
6
|
|
|
7
|
-
This is the Ruby
|
|
7
|
+
This is the Ruby package of [Lattice](https://github.com/tylerklose/lattice) ([lattice-cli on PyPI](https://pypi.org/project/lattice-cli/)). Both packages generate scenarios with the same Rust engine, so they give **byte-identical output** for the same schema, seed and pool size. The test suite checks that by running both and comparing their output. Use whichever runtime your project already has.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -19,6 +19,8 @@ or standalone:
|
|
|
19
19
|
gem install latticerb
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
The gem ships precompiled for macOS (Apple Silicon and Intel), Linux (x86-64 and ARM64, glibc and musl) and Windows (x86-64). On other platforms it compiles the engine on install, which needs a Rust toolchain. JRuby isn't supported, because it can't load native extensions. If your Gemfile.lock was created on a Mac and you deploy to Linux, add the platform once: `bundle lock --add-platform x86_64-linux`.
|
|
23
|
+
|
|
22
24
|
## Library usage
|
|
23
25
|
|
|
24
26
|
The primary API is designed for test suites:
|
|
@@ -60,7 +62,7 @@ Lattice.scenarios(file: "...") # shortcut straight to the hashes
|
|
|
60
62
|
Lattice.validate!(schema) # raises Lattice::ValidationError / Lattice::ConstraintError
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
Generation options: `strength:` (default 2 = pairwise), `seed:` (default 42), `pool_size
|
|
65
|
+
Generation options: `strength:` (default 2 = pairwise), `seed:` (default 42), `pool_size:` (candidate rows per step, default 16), `max_rows:`, `stop_after_coverage:`.
|
|
64
66
|
|
|
65
67
|
## CLI usage
|
|
66
68
|
|
|
@@ -110,17 +112,45 @@ Every value in a schema (the model name, parameter names and values, and constra
|
|
|
110
112
|
|
|
111
113
|
Changed in 0.3.0: earlier versions converted unquoted numbers and dates, so `0x1F` became `31`, `1.10` became `1.1`, and some values (`.inf`, timestamps) came out differently in the Ruby and Python implementations.
|
|
112
114
|
|
|
115
|
+
## Performance
|
|
116
|
+
|
|
117
|
+
Generation runs in a Rust engine (the `lattice-engine` crate), in two phases:
|
|
118
|
+
|
|
119
|
+
1. **Build.** Rows are added greedily. Each row is the best of several candidates (`pool_size:`, default 16), and each candidate picks, value by value, whatever covers the most combinations still uncovered.
|
|
120
|
+
2. **Shrink.** A row is removed and the gaps are repaired with tabu search, which changes values in the remaining rows without breaking any constraint or forced combination. This repeats while repairs succeed within a fixed step budget.
|
|
121
|
+
|
|
122
|
+
The budget is a number of steps, not a time limit, so the same schema and seed give the same rows on every machine. The rows come out ordered so each adds the most new coverage, which is why `max_rows:` and `stop_after_coverage:` cut the set short with as much coverage as possible.
|
|
123
|
+
|
|
124
|
+
Scenario counts against [PICT](https://github.com/microsoft/pict) 3.7, with default settings for both:
|
|
125
|
+
|
|
126
|
+
| Model | PICT | Lattice 0.4 | Lattice 0.3 |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| 13 parameters × 3 values | 20 | **15** (best known) | 22 |
|
|
129
|
+
| 100 × 2 | 16 | **10** (optimal) | over 60 s |
|
|
130
|
+
| 61 mixed (4^15 3^17 2^29) | 40 | **30** | over 60 s |
|
|
131
|
+
| 12 × 6 | 70 | **60** | 88 (32 s) |
|
|
132
|
+
| 30 × 5 | 66 | **55** | over 60 s |
|
|
133
|
+
| 20 × 10 | 218 | **192** | over 60 s |
|
|
134
|
+
| Strength 3, 10 × 4 | 158 | **140** | 176 (27 s) |
|
|
135
|
+
|
|
136
|
+
Each Lattice 0.4 run above takes 0.04–1.2 s on an Apple M-series laptop. PICT is faster (about 0.02 s) because it stops after the build phase.
|
|
137
|
+
|
|
138
|
+
Changed in 0.4.0: rows differ from 0.3.x for the same seed, and every example needs the same number of scenarios or fewer. `pool_size:` now sets the candidates per row; its default went from 50 to 16.
|
|
139
|
+
|
|
113
140
|
## Parity with the Python implementation
|
|
114
141
|
|
|
115
|
-
|
|
142
|
+
Both packages parse schemas in their own language and hand the engine the same compiled constraints (`ConstraintEngine#engine_constraints` here, `lattice.ipog` in Python), so the engine gets identical input. `test/python_parity_test.rb` shells out to the Python implementation (checkout via `LATTICE_PYTHON_SRC`, or an installed `lattice-cli`) and asserts byte-equal output across formats, seeds, strengths and stopping rules; it skips when no Python implementation is available.
|
|
116
143
|
|
|
117
144
|
Schemas are read with Psych here and PyYAML in Python, but both implementations build values from the parsed text with the same rules (see "Values keep their text"), so a schema means the same thing in either. `test/fixtures/scalar_text.*` pins those rules for both.
|
|
118
145
|
|
|
119
146
|
## Development
|
|
120
147
|
|
|
148
|
+
Building the extension needs a Rust toolchain (`rustup`); the engine comes from crates.io (`lattice-engine`).
|
|
149
|
+
|
|
121
150
|
```bash
|
|
122
151
|
bundle install
|
|
123
|
-
bundle exec rake
|
|
152
|
+
bundle exec rake compile # builds lib/lattice/lattice_native
|
|
153
|
+
bundle exec rake test # unit, golden-parity, and live Python-parity suites (compiles first)
|
|
124
154
|
bundle exec mutant run # mutation testing (see docs/mutation-testing.md)
|
|
125
155
|
```
|
|
126
156
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "lattice_native"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
publish = false
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
name = "lattice_native"
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
lattice-engine = { version = "0.1.0", default-features = false }
|
|
13
|
+
magnus = "0.9"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
max_width = 120
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//! Ruby bindings for the Lattice engine (`Lattice::Engine::Model`).
|
|
2
|
+
//!
|
|
3
|
+
//! `Model.new(sizes, weights, strength, invalid_pairs, requires, higher_order, forced)`
|
|
4
|
+
//! takes a schema compiled to the engine's constraint primitives and answers:
|
|
5
|
+
//! `feasibility` (raises ForcedInfeasible with the forced interaction's index,
|
|
6
|
+
//! or NoValidAssignments), `can_complete?(assignments)`, and `generate(...)`.
|
|
7
|
+
use std::cell::RefCell;
|
|
8
|
+
|
|
9
|
+
use lattice_engine::{Assignment, Engine, Error as EngineError, Model as EngineModel, Options};
|
|
10
|
+
use magnus::{Error, ExceptionClass, Module, Ruby, function, method, prelude::*, value::Lazy};
|
|
11
|
+
|
|
12
|
+
type Pair = (Assignment, Assignment);
|
|
13
|
+
|
|
14
|
+
static ENGINE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
15
|
+
ruby.class_object()
|
|
16
|
+
.const_get::<_, magnus::RModule>("Lattice")
|
|
17
|
+
.and_then(|lattice| lattice.const_get::<_, magnus::RModule>("Engine"))
|
|
18
|
+
.and_then(|engine| engine.const_get("ForcedInfeasible"))
|
|
19
|
+
.unwrap()
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
static NO_VALID: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
23
|
+
ruby.class_object()
|
|
24
|
+
.const_get::<_, magnus::RModule>("Lattice")
|
|
25
|
+
.and_then(|lattice| lattice.const_get::<_, magnus::RModule>("Engine"))
|
|
26
|
+
.and_then(|engine| engine.const_get("NoValidAssignments"))
|
|
27
|
+
.unwrap()
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
fn to_ruby(ruby: &Ruby, error: EngineError) -> Error {
|
|
31
|
+
match error {
|
|
32
|
+
EngineError::ForcedInfeasible(index) => Error::new(ruby.get_inner(&ENGINE_ERROR), index.to_string()),
|
|
33
|
+
EngineError::NoValidAssignments => Error::new(ruby.get_inner(&NO_VALID), "no valid assignments"),
|
|
34
|
+
other => Error::new(ruby.exception_arg_error(), format!("{other:?}")),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[magnus::wrap(class = "Lattice::Engine::Model", free_immediately)]
|
|
39
|
+
struct Model(RefCell<Engine>);
|
|
40
|
+
|
|
41
|
+
impl Model {
|
|
42
|
+
fn new(
|
|
43
|
+
ruby: &Ruby,
|
|
44
|
+
sizes: Vec<usize>,
|
|
45
|
+
weights: Vec<Vec<f64>>,
|
|
46
|
+
strength: usize,
|
|
47
|
+
invalid_pairs: Vec<Pair>,
|
|
48
|
+
requires: Vec<Pair>,
|
|
49
|
+
higher_order: Vec<(Vec<Assignment>, Assignment)>,
|
|
50
|
+
forced: Vec<Vec<Assignment>>,
|
|
51
|
+
) -> Result<Self, Error> {
|
|
52
|
+
let model = EngineModel {
|
|
53
|
+
sizes,
|
|
54
|
+
weights,
|
|
55
|
+
strength,
|
|
56
|
+
invalid_pairs,
|
|
57
|
+
requires,
|
|
58
|
+
higher_order,
|
|
59
|
+
forced,
|
|
60
|
+
};
|
|
61
|
+
Engine::new(model)
|
|
62
|
+
.map(|engine| Model(RefCell::new(engine)))
|
|
63
|
+
.map_err(|e| to_ruby(ruby, e))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn feasibility(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
67
|
+
rb_self.0.borrow_mut().feasibility().map_err(|e| to_ruby(ruby, e))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn can_complete(ruby: &Ruby, rb_self: &Self, assignments: Vec<Assignment>) -> Result<bool, Error> {
|
|
71
|
+
rb_self
|
|
72
|
+
.0
|
|
73
|
+
.borrow_mut()
|
|
74
|
+
.can_complete(&assignments)
|
|
75
|
+
.map_err(|e| to_ruby(ruby, e))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Returns [rows ordered by coverage, new tuples per row, valid tuples].
|
|
79
|
+
fn generate(
|
|
80
|
+
ruby: &Ruby,
|
|
81
|
+
rb_self: &Self,
|
|
82
|
+
seed: u64,
|
|
83
|
+
candidates: usize,
|
|
84
|
+
shrink_steps: u64,
|
|
85
|
+
patience: u64,
|
|
86
|
+
) -> Result<(Vec<Vec<usize>>, Vec<usize>, usize), Error> {
|
|
87
|
+
let options = Options {
|
|
88
|
+
seed,
|
|
89
|
+
candidates: candidates.max(1),
|
|
90
|
+
shrink_steps,
|
|
91
|
+
patience,
|
|
92
|
+
};
|
|
93
|
+
let result = rb_self
|
|
94
|
+
.0
|
|
95
|
+
.borrow_mut()
|
|
96
|
+
.generate(&options)
|
|
97
|
+
.map_err(|e| to_ruby(ruby, e))?;
|
|
98
|
+
Ok((result.rows, result.new_coverage, result.valid_tuples))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[magnus::init]
|
|
103
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
104
|
+
let engine = ruby.define_module("Lattice")?.define_module("Engine")?;
|
|
105
|
+
let base = engine.define_error("Error", ruby.exception_standard_error())?;
|
|
106
|
+
engine.define_error("ForcedInfeasible", base)?;
|
|
107
|
+
engine.define_error("NoValidAssignments", base)?;
|
|
108
|
+
let model = engine.define_class("Model", ruby.class_object())?;
|
|
109
|
+
model.define_singleton_method("new", function!(Model::new, 7))?;
|
|
110
|
+
model.define_method("feasibility", method!(Model::feasibility, 0))?;
|
|
111
|
+
model.define_method("can_complete?", method!(Model::can_complete, 1))?;
|
|
112
|
+
model.define_method("generate", method!(Model::generate, 4))?;
|
|
113
|
+
Ok(())
|
|
114
|
+
}
|
data/lib/lattice/cli.rb
CHANGED
|
@@ -59,7 +59,7 @@ module Lattice
|
|
|
59
59
|
options = {
|
|
60
60
|
format: "json",
|
|
61
61
|
seed: 42,
|
|
62
|
-
pool_size:
|
|
62
|
+
pool_size: 16,
|
|
63
63
|
progress: false,
|
|
64
64
|
progress_every: 10
|
|
65
65
|
}
|
|
@@ -67,7 +67,7 @@ module Lattice
|
|
|
67
67
|
opts.on("--strength STRENGTH", Integer)
|
|
68
68
|
opts.on("--format FORMAT", %w[table json csv summary yaml])
|
|
69
69
|
opts.on("--seed SEED", Integer)
|
|
70
|
-
opts.on("--pool-size SIZE", Integer) { |value| options[:pool_size] = value }
|
|
70
|
+
opts.on("--pool-size SIZE", Integer, "Candidate rows built per step; more can mean fewer scenarios.") { |value| options[:pool_size] = value }
|
|
71
71
|
opts.on("--max-rows ROWS", Integer) { |value| options[:max_rows] = value }
|
|
72
72
|
opts.on("--stop-after-coverage PCT", Float) { |value| options[:stop_after_coverage] = value }
|
|
73
73
|
opts.on("--progress")
|
|
@@ -117,7 +117,7 @@ module Lattice
|
|
|
117
117
|
def load_and_check_model(model_path, options)
|
|
118
118
|
model = Parser.load_model(model_path, strength_override: options[:strength])
|
|
119
119
|
engine = ConstraintEngine.new(model)
|
|
120
|
-
raise ValidationError, ["Model has no valid complete assignments."] unless engine.
|
|
120
|
+
raise ValidationError, ["Model has no valid complete assignments."] unless engine.satisfiable?
|
|
121
121
|
|
|
122
122
|
model
|
|
123
123
|
rescue ValidationError => e
|
data/lib/lattice/constraints.rb
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
require "set"
|
|
4
4
|
|
|
5
5
|
require_relative "model"
|
|
6
|
+
require_relative "native"
|
|
6
7
|
|
|
7
8
|
module Lattice
|
|
8
9
|
class ConstraintError < StandardError; end
|
|
9
10
|
|
|
10
|
-
# Compiles model constraints
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# Compiles a model's constraints to the engine's primitives (invalid pairs,
|
|
12
|
+
# requirements, higher-order exclusions, forced interactions), checks the
|
|
13
|
+
# compiled closure for contradictions, and asks the Rust engine whether the
|
|
14
|
+
# model and its forced interactions are satisfiable. Python's
|
|
15
|
+
# lattice.constraints does the same, so both report the same errors.
|
|
16
|
+
# Assignments are `[parameter_index, value_index]` pairs.
|
|
14
17
|
class ConstraintEngine
|
|
15
18
|
EMPTY_SET = Set.new.freeze
|
|
16
19
|
|
|
17
|
-
attr_reader :model, :forced_interactions
|
|
20
|
+
attr_reader :model, :forced_interactions, :native
|
|
18
21
|
|
|
19
22
|
def initialize(model)
|
|
20
23
|
@model = model
|
|
@@ -29,51 +32,56 @@ module Lattice
|
|
|
29
32
|
@requires = {}
|
|
30
33
|
@higher_order = []
|
|
31
34
|
@forced_interactions = []
|
|
32
|
-
@feasibility_cache = {}
|
|
33
35
|
compile_constraints
|
|
34
36
|
compute_implied_pairs
|
|
35
37
|
validate_compiled_constraints
|
|
38
|
+
@native = native_model(model.strength)
|
|
39
|
+
@satisfiable = check_feasibility
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def implied_requirements(assignment)
|
|
43
|
-
requires_for(to_ref(assignment)).map { |ref| from_ref(ref) }.sort
|
|
42
|
+
# Whether any row satisfies every constraint.
|
|
43
|
+
def satisfiable?
|
|
44
|
+
@satisfiable
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
# Whether some valid row contains every assignment in `partial`
|
|
48
|
+
# (a `{parameter_index => value_index}` hash).
|
|
46
49
|
def can_complete?(partial)
|
|
47
|
-
|
|
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
|
|
50
|
+
native.can_complete?(partial.sort)
|
|
57
51
|
end
|
|
58
52
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
def native_model(strength)
|
|
54
|
+
compiled = engine_constraints
|
|
55
|
+
Engine::Model.new(
|
|
56
|
+
model.parameters.map { |parameter| parameter.values.length },
|
|
57
|
+
model.parameters.map(&:normalized_weights),
|
|
58
|
+
strength,
|
|
59
|
+
compiled.fetch(:invalid_pairs),
|
|
60
|
+
compiled.fetch(:requires),
|
|
61
|
+
compiled.fetch(:higher_order),
|
|
62
|
+
compiled.fetch(:forced)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
def conflicts?(left, right)
|
|
67
|
+
invalid_pairs_for(to_ref(left)).include?(to_ref(right))
|
|
64
68
|
end
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
# The compiled constraints as the generation engine takes them: invalid
|
|
71
|
+
# pairs and requirements as sorted [[p, v], [q, w]] lists, higher-order
|
|
72
|
+
# exclusions and forced interactions in compile order. Python's
|
|
73
|
+
# lattice.ipog builds the same lists, so the engine gets identical input.
|
|
74
|
+
def engine_constraints
|
|
75
|
+
{
|
|
76
|
+
invalid_pairs: ref_pairs(@invalid_pairs),
|
|
77
|
+
requires: ref_pairs(@requires),
|
|
78
|
+
higher_order: @higher_order,
|
|
79
|
+
forced: @forced_interactions
|
|
80
|
+
}
|
|
81
|
+
end
|
|
70
82
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return false if normalized[forbidden[0]] == forbidden[1]
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
true
|
|
83
|
+
def implied_requirements(assignment)
|
|
84
|
+
requires_for(to_ref(assignment)).map { |ref| from_ref(ref) }.sort
|
|
77
85
|
end
|
|
78
86
|
|
|
79
87
|
private
|
|
@@ -187,125 +195,21 @@ module Lattice
|
|
|
187
195
|
end
|
|
188
196
|
end
|
|
189
197
|
|
|
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
198
|
end
|
|
225
199
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
200
|
+
# Raises for the first impossible forced interaction; otherwise returns
|
|
201
|
+
# whether the model has any valid row.
|
|
202
|
+
def check_feasibility
|
|
203
|
+
native.feasibility
|
|
240
204
|
true
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 }
|
|
205
|
+
rescue Engine::ForcedInfeasible => e
|
|
206
|
+
assignments = @forced_interactions.fetch(Integer(e.message)).map do |parameter_index, value_index|
|
|
207
|
+
parameter = model.parameters[parameter_index]
|
|
208
|
+
"#{parameter.name}=#{parameter.values[value_index]}"
|
|
209
|
+
end.join(", ")
|
|
210
|
+
raise ConstraintError, "Forced interaction is not feasible: #{assignments}."
|
|
211
|
+
rescue Engine::NoValidAssignments
|
|
212
|
+
false
|
|
309
213
|
end
|
|
310
214
|
|
|
311
215
|
def exclude_other_values(source, required)
|
|
@@ -330,6 +234,10 @@ module Lattice
|
|
|
330
234
|
@invalid_pairs[ref] || EMPTY_SET
|
|
331
235
|
end
|
|
332
236
|
|
|
237
|
+
def ref_pairs(table)
|
|
238
|
+
table.flat_map { |source, targets| targets.map { |target| [source, target] } }.sort
|
|
239
|
+
end
|
|
240
|
+
|
|
333
241
|
def requires_for(ref)
|
|
334
242
|
@requires[ref] || EMPTY_SET
|
|
335
243
|
end
|
data/lib/lattice/formatters.rb
CHANGED
data/lib/lattice/ipog.rb
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "set"
|
|
4
|
-
|
|
5
3
|
require_relative "constraints"
|
|
6
|
-
require_relative "mersenne_twister"
|
|
7
4
|
|
|
8
5
|
module Lattice
|
|
9
6
|
CoveragePoint = Data.define(:scenario, :cumulative_pct)
|
|
10
7
|
GenerationResult = Data.define(:rows, :coverage_curve, :total_valid_tuples)
|
|
11
8
|
GenerationProgress = Data.define(:event, :scenario, :covered, :total, :cumulative_pct, :uncovered)
|
|
12
9
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
10
|
+
# Scenario generation, backed by the Rust engine (Lattice::Engine, built from
|
|
11
|
+
# ext/lattice_native on the lattice-engine crate). The engine builds a covering
|
|
12
|
+
# array greedily, choosing each row from several candidates, then shrinks it
|
|
13
|
+
# with tabu search, and returns the rows ordered so each adds the most new
|
|
14
|
+
# coverage. ConstraintEngine compiles the model into the engine's input; this
|
|
15
|
+
# module applies max_rows and stop_after_coverage by cutting the ordered rows
|
|
16
|
+
# short. Python's lattice.ipog does the same, so both give identical rows.
|
|
17
17
|
module IPOG
|
|
18
18
|
extend self
|
|
19
19
|
|
|
20
|
+
DEFAULT_CANDIDATES = 16
|
|
21
|
+
SHRINK_STEPS = 1_000_000
|
|
22
|
+
PATIENCE = 10_000
|
|
23
|
+
SEED_MODULUS = 2**64
|
|
24
|
+
|
|
25
|
+
# `pool_size` is the number of candidate rows built per greedy step.
|
|
20
26
|
def generate_covering_array(
|
|
21
27
|
model,
|
|
22
28
|
strength: nil,
|
|
23
29
|
seed: 42,
|
|
24
|
-
pool_size:
|
|
30
|
+
pool_size: DEFAULT_CANDIDATES,
|
|
25
31
|
progress: nil,
|
|
26
32
|
max_rows: nil,
|
|
27
33
|
stop_after_coverage: nil
|
|
@@ -33,125 +39,43 @@ module Lattice
|
|
|
33
39
|
coverage_target = coverage_target_for(stop_after_coverage)
|
|
34
40
|
|
|
35
41
|
engine = ConstraintEngine.new(model)
|
|
36
|
-
raise ArgumentError, "Model has no valid full assignments." unless engine.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
42
|
+
raise ArgumentError, "Model has no valid full assignments." unless engine.satisfiable?
|
|
43
|
+
|
|
44
|
+
native = actual_strength == model.strength ? engine.native : engine.native_model(actual_strength)
|
|
45
|
+
rows, new_coverage, total = native.generate(seed % SEED_MODULUS, pool_size, SHRINK_STEPS, PATIENCE)
|
|
46
|
+
|
|
47
|
+
report(progress, "enumerated", 0, 0, total)
|
|
48
|
+
kept = []
|
|
49
|
+
curve = []
|
|
50
|
+
covered = 0
|
|
51
|
+
rows.zip(new_coverage) do |row, gained|
|
|
52
|
+
kept << row.freeze
|
|
53
|
+
covered += gained
|
|
54
|
+
cumulative_pct = coverage_pct(covered, total)
|
|
55
|
+
curve << CoveragePoint.new(scenario: kept.length, cumulative_pct:)
|
|
56
|
+
report(progress, "scenario", kept.length, covered, total)
|
|
57
|
+
next unless should_stop?(kept.length, cumulative_pct, max_rows, coverage_target)
|
|
58
|
+
|
|
59
|
+
report(progress, "stopped", kept.length, covered, total) if kept.length < rows.length
|
|
60
|
+
break
|
|
98
61
|
end
|
|
99
62
|
|
|
100
|
-
|
|
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
|
-
)
|
|
63
|
+
GenerationResult.new(rows: kept.freeze, coverage_curve: curve.freeze, total_valid_tuples: total)
|
|
117
64
|
end
|
|
118
65
|
|
|
119
|
-
|
|
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
|
|
66
|
+
private
|
|
148
67
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
68
|
+
def report(progress, event, scenario, covered, total)
|
|
69
|
+
progress&.call(
|
|
70
|
+
GenerationProgress.new(
|
|
71
|
+
event:,
|
|
72
|
+
scenario:,
|
|
73
|
+
covered:,
|
|
74
|
+
total:,
|
|
75
|
+
cumulative_pct: coverage_pct(covered, total),
|
|
76
|
+
uncovered: total - covered
|
|
77
|
+
)
|
|
78
|
+
)
|
|
155
79
|
end
|
|
156
80
|
|
|
157
81
|
def coverage_target_for(stop_after_coverage)
|
|
@@ -160,7 +84,6 @@ module Lattice
|
|
|
160
84
|
unless stop_after_coverage.positive? && stop_after_coverage <= 100
|
|
161
85
|
raise ArgumentError, "`stop_after_coverage` must be in the range (0, 100]."
|
|
162
86
|
end
|
|
163
|
-
return nil if stop_after_coverage >= 100
|
|
164
87
|
|
|
165
88
|
stop_after_coverage
|
|
166
89
|
end
|
|
@@ -172,51 +95,9 @@ module Lattice
|
|
|
172
95
|
false
|
|
173
96
|
end
|
|
174
97
|
|
|
175
|
-
|
|
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
|
-
|
|
98
|
+
# A satisfiable model always has valid tuples, so total_count is positive.
|
|
212
99
|
def coverage_pct(covered_count, total_count)
|
|
213
|
-
return 100.0 if total_count.zero?
|
|
214
|
-
|
|
215
100
|
((covered_count.to_f / total_count) * 100).round(1, half: :even)
|
|
216
101
|
end
|
|
217
|
-
|
|
218
|
-
def reverse_lex(row)
|
|
219
|
-
row.map(&:-@)
|
|
220
|
-
end
|
|
221
102
|
end
|
|
222
103
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loads the Rust engine (Lattice::Engine). Precompiled gems carry one build per
|
|
4
|
+
# Ruby version (lib/lattice/3.4/...); a gem compiled on install has a single one
|
|
5
|
+
# next to this file.
|
|
6
|
+
begin
|
|
7
|
+
require_relative "#{RUBY_VERSION[/\A\d+\.\d+/]}/lattice_native"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
require_relative "lattice_native"
|
|
10
|
+
end
|
data/lib/lattice/version.rb
CHANGED
data/lib/lattice.rb
CHANGED
|
@@ -57,7 +57,7 @@ module Lattice
|
|
|
57
57
|
#
|
|
58
58
|
# run = Lattice.generate(file: "spec/fixtures/checkout.yaml")
|
|
59
59
|
# run.scenarios.each { |row| ... }
|
|
60
|
-
def generate(source = nil, file: nil, strength: nil, seed: 42, pool_size:
|
|
60
|
+
def generate(source = nil, file: nil, strength: nil, seed: 42, pool_size: 16,
|
|
61
61
|
max_rows: nil, stop_after_coverage: nil, progress: nil)
|
|
62
62
|
model = parse_model(source, file:, strength:)
|
|
63
63
|
result = IPOG.generate_covering_array(
|
|
@@ -80,7 +80,7 @@ module Lattice
|
|
|
80
80
|
def validate!(source = nil, file: nil, strength: nil)
|
|
81
81
|
model = parse_model(source, file:, strength:)
|
|
82
82
|
engine = ConstraintEngine.new(model)
|
|
83
|
-
raise ValidationError, ["Model has no valid complete assignments."] unless engine.
|
|
83
|
+
raise ValidationError, ["Model has no valid complete assignments."] unless engine.satisfiable?
|
|
84
84
|
|
|
85
85
|
model
|
|
86
86
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: latticerb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tyler Klose
|
|
@@ -23,28 +23,50 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '3.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rb_sys
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.9'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.9'
|
|
26
40
|
description: Lattice generates deterministic pairwise/t-way covering arrays from a
|
|
27
41
|
constrained schema of parameters and values. Use it as a library in test suites
|
|
28
|
-
(RSpec, Minitest, Rails fixtures) or via the bundled `lattice` CLI.
|
|
29
|
-
|
|
42
|
+
(RSpec, Minitest, Rails fixtures) or via the bundled `lattice` CLI. Generation runs
|
|
43
|
+
in a Rust engine shared with the Python lattice-cli package, so both give identical
|
|
44
|
+
rows for the same schema and seed.
|
|
30
45
|
email:
|
|
31
46
|
- tylerklose@gmail.com
|
|
32
47
|
executables:
|
|
33
48
|
- lattice
|
|
34
|
-
extensions:
|
|
49
|
+
extensions:
|
|
50
|
+
- ext/lattice_native/extconf.rb
|
|
35
51
|
extra_rdoc_files: []
|
|
36
52
|
files:
|
|
53
|
+
- Cargo.lock
|
|
54
|
+
- Cargo.toml
|
|
37
55
|
- LICENSE
|
|
38
56
|
- README.md
|
|
39
57
|
- exe/lattice
|
|
58
|
+
- ext/lattice_native/Cargo.toml
|
|
59
|
+
- ext/lattice_native/extconf.rb
|
|
60
|
+
- ext/lattice_native/rustfmt.toml
|
|
61
|
+
- ext/lattice_native/src/lib.rs
|
|
40
62
|
- lib/lattice.rb
|
|
41
63
|
- lib/lattice/agent.rb
|
|
42
64
|
- lib/lattice/cli.rb
|
|
43
65
|
- lib/lattice/constraints.rb
|
|
44
66
|
- lib/lattice/formatters.rb
|
|
45
67
|
- lib/lattice/ipog.rb
|
|
46
|
-
- lib/lattice/mersenne_twister.rb
|
|
47
68
|
- lib/lattice/model.rb
|
|
69
|
+
- lib/lattice/native.rb
|
|
48
70
|
- lib/lattice/parser.rb
|
|
49
71
|
- lib/lattice/resources/claude/lattice-workflow/SKILL.md
|
|
50
72
|
- lib/lattice/resources/claude/lattice-workflow/references/modeling-rules.md
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Lattice
|
|
4
|
-
# MT19937 matching CPython's `random.Random` bit-for-bit — same integer
|
|
5
|
-
# seeding (init_by_array over 32-bit words), same 53-bit `random`, same
|
|
6
|
-
# little-endian word order for `getrandbits` — so a given seed produces
|
|
7
|
-
# identical covering arrays here and in the Python implementation.
|
|
8
|
-
class MersenneTwister
|
|
9
|
-
N = 624
|
|
10
|
-
M = 397
|
|
11
|
-
MATRIX_A = 0x9908b0df
|
|
12
|
-
UPPER_MASK = 0x80000000
|
|
13
|
-
LOWER_MASK = 0x7fffffff
|
|
14
|
-
MASK32 = 0xffffffff
|
|
15
|
-
|
|
16
|
-
def initialize(seed)
|
|
17
|
-
seed = seed.abs
|
|
18
|
-
key = []
|
|
19
|
-
while seed.positive?
|
|
20
|
-
key << (seed & MASK32)
|
|
21
|
-
seed >>= 32
|
|
22
|
-
end
|
|
23
|
-
key << 0 if key.empty?
|
|
24
|
-
init_by_array(key)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def random
|
|
28
|
-
a = genrand_uint32 >> 5
|
|
29
|
-
b = genrand_uint32 >> 6
|
|
30
|
-
(a * 67_108_864.0 + b) * (1.0 / 9_007_199_254_740_992.0)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def getrandbits(bits)
|
|
34
|
-
result = 0
|
|
35
|
-
shift = 0
|
|
36
|
-
while bits.positive?
|
|
37
|
-
word = genrand_uint32
|
|
38
|
-
word >>= (32 - bits) if bits < 32
|
|
39
|
-
result |= word << shift
|
|
40
|
-
shift += 32
|
|
41
|
-
bits -= 32
|
|
42
|
-
end
|
|
43
|
-
result
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def init_genrand(seed)
|
|
49
|
-
@mt = Array.new(N)
|
|
50
|
-
@mt[0] = seed & MASK32
|
|
51
|
-
(1...N).each do |i|
|
|
52
|
-
@mt[i] = (1_812_433_253 * (@mt[i - 1] ^ (@mt[i - 1] >> 30)) + i) & MASK32
|
|
53
|
-
end
|
|
54
|
-
@mti = N
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def init_by_array(key)
|
|
58
|
-
init_genrand(19_650_218)
|
|
59
|
-
i = 1
|
|
60
|
-
j = 0
|
|
61
|
-
count = [N, key.length].max
|
|
62
|
-
while count.positive?
|
|
63
|
-
@mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_664_525)) + key[j] + j) & MASK32
|
|
64
|
-
i += 1
|
|
65
|
-
j += 1
|
|
66
|
-
if i >= N
|
|
67
|
-
@mt[0] = @mt[N - 1]
|
|
68
|
-
i = 1
|
|
69
|
-
end
|
|
70
|
-
j = 0 if j >= key.length
|
|
71
|
-
count -= 1
|
|
72
|
-
end
|
|
73
|
-
count = N - 1
|
|
74
|
-
while count.positive?
|
|
75
|
-
@mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_566_083_941)) - i) & MASK32
|
|
76
|
-
i += 1
|
|
77
|
-
if i >= N
|
|
78
|
-
@mt[0] = @mt[N - 1]
|
|
79
|
-
i = 1
|
|
80
|
-
end
|
|
81
|
-
count -= 1
|
|
82
|
-
end
|
|
83
|
-
@mt[0] = 0x80000000
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def genrand_uint32
|
|
87
|
-
if @mti >= N
|
|
88
|
-
(0...N).each do |kk|
|
|
89
|
-
y = (@mt[kk] & UPPER_MASK) | (@mt[(kk + 1) % N] & LOWER_MASK)
|
|
90
|
-
@mt[kk] = @mt[(kk + M) % N] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
|
|
91
|
-
end
|
|
92
|
-
@mti = 0
|
|
93
|
-
end
|
|
94
|
-
y = @mt[@mti]
|
|
95
|
-
@mti += 1
|
|
96
|
-
y ^= (y >> 11)
|
|
97
|
-
y = (y ^ ((y << 7) & 0x9d2c5680)) & MASK32
|
|
98
|
-
y = (y ^ ((y << 15) & 0xefc60000)) & MASK32
|
|
99
|
-
y ^ (y >> 18)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|