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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f977e711ce31ec3c0355472e32dcff2f95cb82c173d8cbb983f05d9f8c49fb83
4
- data.tar.gz: 73384303ddfe7fe6cbfef0cc72166d3a79ec975251a1be88a2c1560e02c594db
3
+ metadata.gz: e8b8ff092e386afd2f650b4b9915d837bab09cae92817516403ec373beba6997
4
+ data.tar.gz: 4b68178ac100e814c2613a3bd8ec724a60701fb5ae36a3f93038743f167a4b1a
5
5
  SHA512:
6
- metadata.gz: 3d50633f9b3c3e6824811806ad8732fbfa7e12812eb94685a03cf2263254195243f44887cbe8984c297f8d05b800063367d7d5aca6413216cace5f7e3d869262
7
- data.tar.gz: 2b0a3695bb8c6a00a8856aaa143aec10e488088e5f0800738dd3ddeefdd299fde8fd13010f406f680e085ee8c52ec6a6d0a0b7157eb993bbcbb04f8c58678817
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
@@ -0,0 +1,7 @@
1
+ [workspace]
2
+ members = ["ext/lattice_native"]
3
+ resolver = "3"
4
+
5
+ [profile.release]
6
+ lto = true
7
+ codegen-units = 1
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 implementation of [Lattice](https://github.com/tylerklose/lattice) ([lattice-cli on PyPI](https://pypi.org/project/lattice-cli/)). Same schema format, same algorithm, same seeds — **byte-identical output** for the same schema, seed, and pool size. That parity is enforced by the test suite, which runs both implementations and diffs their output. Use whichever runtime your project already has.
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:`, `max_rows:`, `stop_after_coverage:`.
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
- The generator seeds an MT19937 RNG that reproduces CPython's `random.Random` bit-for-bit, so both implementations walk the same candidate pools and pick the same rows. `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.
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 test # unit, golden-parity, and live Python-parity suites
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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("lattice/lattice_native")
@@ -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: 50,
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.can_complete?({})
120
+ raise ValidationError, ["Model has no valid complete assignments."] unless engine.satisfiable?
121
121
 
122
122
  model
123
123
  rescue ValidationError => e
@@ -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 into invalid-pair / requirement closures and
11
- # answers feasibility questions over partial assignments. Assignments are
12
- # referenced as `[parameter_index, value_index]` pairs; partial assignments
13
- # are `{parameter_index => value_index}` hashes.
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
- def conflicts?(left, right)
39
- invalid_pairs_for(to_ref(left)).include?(to_ref(right))
40
- end
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
- normalized = normalize_partial(partial.dup)
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 complete_assignment(partial, rng: nil)
60
- normalized = normalize_partial(partial.dup)
61
- return nil if normalized.nil?
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
- backtrack_complete(normalized, rng:, failures: Set.new)
66
+ def conflicts?(left, right)
67
+ invalid_pairs_for(to_ref(left)).include?(to_ref(right))
64
68
  end
65
69
 
66
- def complete_assignment_valid?(row)
67
- partial = row.each_with_index.to_h { |value_index, parameter_index| [parameter_index, value_index] }
68
- normalized = normalize_partial(partial)
69
- return false if normalized.nil? || normalized.length != model.parameters.length
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
- @higher_order.each do |antecedents, forbidden|
72
- if antecedents.all? { |parameter_index, value_index| normalized[parameter_index] == value_index }
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
- def partial_consistent?(partial)
227
- partial.each do |parameter_index, value_index|
228
- partial.each do |other_parameter, other_value|
229
- next if parameter_index == other_parameter
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
- end
242
-
243
- def backtrack_complete(partial, rng:, failures:)
244
- signature = partial.sort
245
- return nil if failures.include?(signature)
246
-
247
- if partial.length == model.parameters.length
248
- row = (0...model.parameters.length).map { |index| partial.fetch(index) }
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
@@ -180,7 +180,7 @@ module Lattice
180
180
 
181
181
  reduction = exhaustive_count.to_f / scenario_count
182
182
  if (reduction - reduction.round).abs < 1e-9
183
- "#{reduction.round(half: :even)}x"
183
+ "#{reduction.round}x"
184
184
  else
185
185
  format("%.1fx", reduction)
186
186
  end
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
- # Greedy covering-array generation (IPOG-style): enumerate every valid
14
- # t-way interaction, then repeatedly extend the lowest uncovered tuple into
15
- # the highest-scoring full row from a seeded candidate pool. Deterministic
16
- # for a given seed, and row-for-row identical to the Python implementation.
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: 50,
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.can_complete?({})
37
-
38
- valid_tuples = enumerate_valid_tuples(model, engine, actual_strength, progress:)
39
- uncovered = Set.new(valid_tuples)
40
- rows = []
41
- coverage_points = []
42
- rng = MersenneTwister.new(seed)
43
- limit_reached = false
44
- progress&.call(
45
- GenerationProgress.new(
46
- event: "enumerated",
47
- scenario: 0,
48
- covered: 0,
49
- total: valid_tuples.length,
50
- cumulative_pct: coverage_pct(0, valid_tuples.length),
51
- uncovered: uncovered.length
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
- while !uncovered.empty? && !limit_reached
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
- def enumerate_valid_tuples(model, engine, strength, progress: nil)
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 candidate_tuple_count(model, strength)
150
- total = 0
151
- (0...model.parameters.length).to_a.combination(strength) do |parameter_combo|
152
- total += parameter_combo.inject(1) { |count, parameter_index| count * model.parameters[parameter_index].values.length }
153
- end
154
- total
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
- def best_candidate_row(model, engine, partial, uncovered, strength, pool_size, rng)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lattice
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
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: 50,
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.can_complete?({})
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.3.1
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. Output is row-for-row
29
- identical to the Python lattice-cli package for the same schema and seed.
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