rete-graph 0.3.3__tar.gz → 0.3.4__tar.gz

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.
Files changed (95) hide show
  1. {rete_graph-0.3.3 → rete_graph-0.3.4}/Cargo.toml +1 -1
  2. {rete_graph-0.3.3 → rete_graph-0.3.4}/PKG-INFO +1 -1
  3. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/Cargo.lock +2 -2
  4. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/Cargo.toml +1 -1
  5. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/lib.rs +2 -1
  6. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/pyramid.rs +239 -20
  7. rete_graph-0.3.4/crates/rete-core/tests/fixtures/louvain-hangs/jev-commander-145.nt +149 -0
  8. rete_graph-0.3.4/crates/rete-core/tests/fixtures/louvain-hangs/jev-random30-30.nt +34 -0
  9. rete_graph-0.3.4/crates/rete-core/tests/fixtures/louvain-hangs/jev-random63-63.nt +67 -0
  10. rete_graph-0.3.4/crates/rete-core/tests/fixtures/louvain-hangs/min-15-level0.nt +20 -0
  11. rete_graph-0.3.4/crates/rete-core/tests/fixtures/louvain-hangs/min-18-level1.nt +21 -0
  12. rete_graph-0.3.4/crates/rete-core/tests/louvain_termination.rs +155 -0
  13. {rete_graph-0.3.3 → rete_graph-0.3.4}/pyproject.toml +1 -1
  14. {rete_graph-0.3.3 → rete_graph-0.3.4}/README.md +0 -0
  15. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/.gitignore +0 -0
  16. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/README.md +0 -0
  17. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/agents/README.md +0 -0
  18. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/agents/langchain_agent.py +0 -0
  19. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/agents/pydantic_ai_agent.py +0 -0
  20. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/agents/rete_tools.py +0 -0
  21. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/graph-data-science.ipynb +0 -0
  22. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/jupyterlite-build-tutorial.ipynb +0 -0
  23. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/jupyterlite-demo.ipynb +0 -0
  24. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/examples/tutorial.ipynb +0 -0
  25. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/src/lib.rs +0 -0
  26. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/src/readers.rs +0 -0
  27. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/tests/conftest.py +0 -0
  28. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/tests/test_rdf12.py +0 -0
  29. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/tests/test_rete_graph.py +0 -0
  30. {rete_graph-0.3.3 → rete_graph-0.3.4}/clients/python/tests/test_warnings.py +0 -0
  31. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/Cargo.toml +0 -0
  32. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/LICENSE +0 -0
  33. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/README.md +0 -0
  34. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/examples/coherence_bench.rs +0 -0
  35. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/examples/parallel_bench.rs +0 -0
  36. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/examples/qbench.rs +0 -0
  37. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/bgp.rs +0 -0
  38. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/block_cache.rs +0 -0
  39. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/card_derive.rs +0 -0
  40. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/card_input.rs +0 -0
  41. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/card_queries.rs +0 -0
  42. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/chunk_cache.rs +0 -0
  43. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/dict.rs +0 -0
  44. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/dictionary.rs +0 -0
  45. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/extbuild.rs +0 -0
  46. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/file.rs +0 -0
  47. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/geo.rs +0 -0
  48. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/geo3.rs +0 -0
  49. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/header.rs +0 -0
  50. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/index.rs +0 -0
  51. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/ingest.rs +0 -0
  52. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/iri.rs +0 -0
  53. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/manifest.rs +0 -0
  54. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/meta.rs +0 -0
  55. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/parallel.rs +0 -0
  56. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/reach.rs +0 -0
  57. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/reader.rs +0 -0
  58. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/reason.rs +0 -0
  59. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/results.rs +0 -0
  60. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/row.rs +0 -0
  61. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/schema_pyramid.rs +0 -0
  62. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/service.rs +0 -0
  63. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/shacl.rs +0 -0
  64. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/aggregate.rs +0 -0
  65. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/diag.rs +0 -0
  66. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/eval.rs +0 -0
  67. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/expr.rs +0 -0
  68. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/lower.rs +0 -0
  69. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/path.rs +0 -0
  70. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql/ql.rs +0 -0
  71. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/sparql.rs +0 -0
  72. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/terms.rs +0 -0
  73. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/text_index.rs +0 -0
  74. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/tiling.rs +0 -0
  75. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/triples.rs +0 -0
  76. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/src/varint.rs +0 -0
  77. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/community_eval.rs +0 -0
  78. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/compatibility_v1.rs +0 -0
  79. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/fixtures/v1/glue-stub.js +0 -0
  80. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/fixtures/v1/minimal.rete +0 -0
  81. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/fixtures/v1/source.nq +0 -0
  82. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/iri_differential.rs +0 -0
  83. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/polyglot_roundtrip.rs +0 -0
  84. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/properties.proptest-regressions +0 -0
  85. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/properties.rs +0 -0
  86. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/public_api.rs +0 -0
  87. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/ranged.rs +0 -0
  88. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/robustness.rs +0 -0
  89. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/roundtrip.rs +0 -0
  90. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/service_federation.rs +0 -0
  91. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/shacl_core.rs +0 -0
  92. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/sparql_integration.rs +0 -0
  93. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/sparql_nondeterministic.rs +0 -0
  94. {rete_graph-0.3.3 → rete_graph-0.3.4}/crates/rete-core/tests/sparql_warnings.rs +0 -0
  95. {rete_graph-0.3.3 → rete_graph-0.3.4}/python/rete_graph/__init__.py +0 -0
@@ -18,7 +18,7 @@ members = [
18
18
  exclude = ["fuzz", "clients/python", "clients/r/src/rust", "clients/java/ffi", "clients/tauri/src-tauri"]
19
19
 
20
20
  [workspace.package]
21
- version = "0.3.3"
21
+ version = "0.3.4"
22
22
  edition = "2021"
23
23
  license = "Apache-2.0"
24
24
  repository = "https://github.com/caviri/rete"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rete-graph
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -627,7 +627,7 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
627
627
 
628
628
  [[package]]
629
629
  name = "rete-core"
630
- version = "0.3.3"
630
+ version = "0.3.4"
631
631
  dependencies = [
632
632
  "blake3",
633
633
  "getrandom 0.2.17",
@@ -651,7 +651,7 @@ dependencies = [
651
651
 
652
652
  [[package]]
653
653
  name = "rete-python"
654
- version = "0.3.3"
654
+ version = "0.3.4"
655
655
  dependencies = [
656
656
  "pyo3",
657
657
  "rete-core",
@@ -6,7 +6,7 @@ name = "rete-python"
6
6
  # Kept in lockstep with pyproject.toml's [project] version, and with the engine's
7
7
  # major.minor, by scripts/sync_versions.py (--check runs in CI). __version__ comes
8
8
  # from here (CARGO_PKG_VERSION), the wheel metadata from pyproject.
9
- version = "0.3.3"
9
+ version = "0.3.4"
10
10
  edition = "2021"
11
11
  license = "Apache-2.0"
12
12
  repository = "https://github.com/caviri/rete"
@@ -227,7 +227,8 @@ pub use meta::{
227
227
  };
228
228
  #[doc(hidden)]
229
229
  pub use pyramid::{
230
- build_dendrogram, louvain_one_level, project_graph, Dendrogram, Graph, Partition, PyramidAlgo,
230
+ build_dendrogram, louvain_one_level, louvain_one_level_stats, project_graph, Dendrogram, Graph,
231
+ LevelStats, Partition, PyramidAlgo, MAX_LOUVAIN_PASSES,
231
232
  };
232
233
  #[doc(hidden)]
233
234
  pub use reach::{batch_reach_serial, build_adjacency, reach_one};
@@ -154,20 +154,127 @@ impl PyramidAlgo {
154
154
  }
155
155
  }
156
156
 
157
+ /// Most local-moving passes one Louvain level may make before it stops and
158
+ /// returns the partition it has.
159
+ ///
160
+ /// A **backstop**, not the termination argument. With the exact move test of
161
+ /// [`louvain_one_level`] every move raises the modularity strictly, so the
162
+ /// passes end on their own. Measured: at most 24 passes on any level of 500
163
+ /// million random graphs, and at most 186 on 21 real datasets of up to 11.7
164
+ /// million triples (proteinbase's base level; the next highest was 77). The
165
+ /// cap is about ten times that worst case (`docs/SPEC.md` §7.1). It exists so
166
+ /// that no future numeric defect, or a caller of the public
167
+ /// [`Graph::from_edges`] with weights the exact path does not cover, can turn
168
+ /// a build into an infinite loop again, as rete 0.3.3 and earlier could (see
169
+ /// [`louvain_one_level`]). Reaching it is not an error: the level ends with
170
+ /// the partition it has, which is valid, merely one a further pass might
171
+ /// still have improved.
172
+ pub const MAX_LOUVAIN_PASSES: usize = 2000;
173
+
174
+ /// What one Louvain level did (for tests and the termination harness).
175
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176
+ pub struct LevelStats {
177
+ /// Local-moving passes made (each a sweep over every node).
178
+ pub passes: usize,
179
+ /// True if the level stopped at [`MAX_LOUVAIN_PASSES`] with nodes still
180
+ /// moving, rather than converging.
181
+ pub capped: bool,
182
+ }
183
+
184
+ /// How a level compares two candidate communities' gains.
185
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
186
+ enum GainTest {
187
+ /// Integer weights and `(2m)² ≤ 2^53`: the cross-multiplied gains are
188
+ /// integers an `f64` holds exactly.
189
+ ExactF64,
190
+ /// Integer weights, larger graph: the same integers in `i128`.
191
+ ExactI128,
192
+ /// Non-integer weights (reachable only through the public
193
+ /// [`Graph::from_edges`]; nothing in rete builds such a graph): the
194
+ /// floating-point gain, where a move counts only when it wins by more than
195
+ /// a bound on its rounding error.
196
+ Tolerant,
197
+ }
198
+
199
+ /// `2^53`: every integer up to it is exactly representable in an `f64`.
200
+ const F64_EXACT_INT: f64 = 9_007_199_254_740_992.0;
201
+
202
+ impl Graph {
203
+ fn gain_test(&self) -> GainTest {
204
+ let two_m = 2.0 * self.m;
205
+ let integral = |x: f64| x.is_finite() && x.fract() == 0.0;
206
+ // Every edge weight integral, and every sum the level forms (degrees,
207
+ // community totals, k_{i,c}, all ≤ 2m) exactly representable.
208
+ let exact_sums = integral(self.m)
209
+ && two_m <= F64_EXACT_INT
210
+ && self
211
+ .adj
212
+ .iter()
213
+ .all(|nbrs| nbrs.iter().all(|&(_, w)| integral(w)));
214
+ if !exact_sums {
215
+ GainTest::Tolerant
216
+ } else if two_m * two_m <= F64_EXACT_INT {
217
+ GainTest::ExactF64
218
+ } else {
219
+ GainTest::ExactI128
220
+ }
221
+ }
222
+ }
223
+
157
224
  /// One level of Louvain local-moving modularity optimization.
158
225
  ///
159
226
  /// Nodes start in their own community; each is greedily moved to the neighboring
160
227
  /// community giving the best modularity gain until no move improves. IDs in the
161
228
  /// result are renumbered densely.
229
+ ///
230
+ /// # The move test is exact
231
+ ///
232
+ /// Moving node `i` (degree `k_i`, with `k_{i,c}` of its edge weight going into
233
+ /// community `c`, whose other members' degrees total `Σ_c`) into `c` gains
234
+ /// `k_{i,c} − Σ_c·k_i / 2m`, up to a positive factor common to every `c`.
235
+ /// Through 0.3.3 that was computed in floating point, and the division by `2m`
236
+ /// is inexact whenever `2m` is not a power of two. Two candidates with *equal*
237
+ /// gains could then round one ulp apart, the strict `>` moved a node on a gain
238
+ /// of zero, a second node did the same the other way, and the passes, which
239
+ /// had no limit, swapped the pair forever. A graph of 15 triples is enough
240
+ /// (`tests/louvain_termination.rs`).
241
+ ///
242
+ /// The test is now made on `k_{i,c}·2m − Σ_c·k_i`, the gain times `2m`, with no
243
+ /// division. Every weight rete builds is an integer count (one per triple,
244
+ /// summed by [`Graph::quotient`]), so these are integers. They are exact in
245
+ /// `f64` while `(2m)² ≤ 2^53`, because every product is at most `(2m)²`
246
+ /// (`k_{i,c} ≤ k_i ≤ 2m` and `Σ_c ≤ 2m`): up to 47,453,132 projected edges.
247
+ /// Beyond that they are computed in `i128`, exact while `2m ≤ 2^53`, the point
248
+ /// where the `f64` sums themselves would stop being exact. So a node moves only
249
+ /// on a strictly positive true gain, each move raises the modularity, and the
250
+ /// passes must end. For non-integer weights (only via the public
251
+ /// [`Graph::from_edges`]) a move must win by more than a bound on the rounding
252
+ /// error, which keeps every accepted move a true improvement as well.
253
+ /// [`MAX_LOUVAIN_PASSES`] backs all of this up.
254
+ ///
255
+ /// Ties keep the old rule: candidates are scanned in sorted order and the
256
+ /// first best one wins (the node's own community, then the smallest id). The
257
+ /// partition is therefore reproducible across runs and platforms; native and
258
+ /// wasm compute the same integers.
162
259
  pub fn louvain_one_level(g: &Graph) -> Partition {
260
+ louvain_one_level_stats(g).0
261
+ }
262
+
263
+ /// [`louvain_one_level`], also returning how many passes it made and whether
264
+ /// it stopped at [`MAX_LOUVAIN_PASSES`].
265
+ pub fn louvain_one_level_stats(g: &Graph) -> (Partition, LevelStats) {
163
266
  let n = g.n;
164
267
  if g.m == 0.0 {
165
- return Partition {
166
- comm: (0..n).collect(),
167
- count: n,
168
- };
268
+ return (
269
+ Partition {
270
+ comm: (0..n).collect(),
271
+ count: n,
272
+ },
273
+ LevelStats::default(),
274
+ );
169
275
  }
170
276
  let two_m = 2.0 * g.m;
277
+ let test = g.gain_test();
171
278
  let mut comm: Vec<usize> = (0..n).collect();
172
279
  let mut sigma_tot: Vec<f64> = g.degree.clone(); // each node alone
173
280
 
@@ -176,13 +283,19 @@ pub fn louvain_one_level(g: &Graph) -> Partition {
176
283
  // recorded in `touched`. A dense array + touched-list replaces the per-node
177
284
  // `HashMap` the textbook formulation allocates — same accumulation order (the
178
285
  // adjacency is index-sorted, so the float sums are bit-identical) and the same
179
- // sorted candidate scan, so the partition is byte-for-byte unchanged. Edge
180
- // weights are strictly positive, so `k_to[c] == 0.0` reliably means "untouched".
286
+ // sorted candidate scan. Edge weights are strictly positive, so
287
+ // `k_to[c] == 0.0` reliably means "untouched".
181
288
  let mut k_to = vec![0.0f64; n];
182
289
  let mut touched: Vec<usize> = Vec::new();
183
290
 
291
+ let mut stats = LevelStats::default();
184
292
  let mut improved = true;
185
293
  while improved {
294
+ if stats.passes == MAX_LOUVAIN_PASSES {
295
+ stats.capped = true;
296
+ break;
297
+ }
298
+ stats.passes += 1;
186
299
  improved = false;
187
300
  for (i, neighbors) in g.adj.iter().enumerate() {
188
301
  let ci = comm[i];
@@ -203,20 +316,56 @@ pub fn louvain_one_level(g: &Graph) -> Partition {
203
316
  }
204
317
  }
205
318
 
206
- // Gain of placing i into community c: k_{i,c} - sigma_tot[c]*ki/2m.
207
- let gain = |c: usize| -> f64 { k_to[c] - sigma_tot[c] * ki / two_m };
208
319
  // Scan candidate communities in a deterministic (sorted) order so an
209
320
  // equal-gain tie always resolves to the same community across runs —
210
321
  // the second half of making the pyramid reproducible. Strict `>`
211
- // keeps the first (smallest-id) community on ties.
212
- let mut best_c = ci;
213
- let mut best_gain = gain(ci);
322
+ // keeps the first candidate (i's own community, then the smallest
323
+ // id) on ties. The gains compared are exact; see the doc comment.
214
324
  touched.sort_unstable();
215
- for &c in &touched {
216
- let gc = gain(c);
217
- if gc > best_gain {
218
- best_gain = gc;
219
- best_c = c;
325
+ let mut best_c = ci;
326
+ match test {
327
+ GainTest::ExactF64 => {
328
+ // Integers ≤ (2m)² ≤ 2^53: exact, and so is their difference.
329
+ let gain = |c: usize| k_to[c] * two_m - sigma_tot[c] * ki;
330
+ let mut best = gain(ci);
331
+ for &c in &touched {
332
+ let gc = gain(c);
333
+ if gc > best {
334
+ best = gc;
335
+ best_c = c;
336
+ }
337
+ }
338
+ }
339
+ GainTest::ExactI128 => {
340
+ // Integers ≤ 2^53, so `as` is exact; products < 2^106.
341
+ let (tm, k) = (two_m as i128, ki as i128);
342
+ let gain = |c: usize| (k_to[c] as i128) * tm - (sigma_tot[c] as i128) * k;
343
+ let mut best = gain(ci);
344
+ for &c in &touched {
345
+ let gc = gain(c);
346
+ if gc > best {
347
+ best = gc;
348
+ best_c = c;
349
+ }
350
+ }
351
+ }
352
+ GainTest::Tolerant => {
353
+ // Both terms of a gain are at most `ki` in magnitude
354
+ // (k_{i,c} ≤ k_i, Σ_c ≤ 2m) and come from at most deg+3
355
+ // rounded operations, so each gain is off by less than
356
+ // (deg+4)·ε·k_i, and a difference of two by twice that. A
357
+ // move must win by more than this margin, so every move
358
+ // accepted is a true gain.
359
+ let tol = 2.0 * (neighbors.len() as f64 + 4.0) * f64::EPSILON * ki;
360
+ let gain = |c: usize| k_to[c] - sigma_tot[c] * ki / two_m;
361
+ let mut best = gain(ci);
362
+ for &c in &touched {
363
+ let gc = gain(c);
364
+ if gc > best + tol {
365
+ best = gc;
366
+ best_c = c;
367
+ }
368
+ }
220
369
  }
221
370
  }
222
371
 
@@ -240,10 +389,13 @@ pub fn louvain_one_level(g: &Graph) -> Partition {
240
389
  let next = remap.len();
241
390
  *c = *remap.entry(*c).or_insert(next);
242
391
  }
243
- Partition {
244
- count: remap.len(),
245
- comm,
246
- }
392
+ (
393
+ Partition {
394
+ count: remap.len(),
395
+ comm,
396
+ },
397
+ stats,
398
+ )
247
399
  }
248
400
 
249
401
  /// Project RDF integer triples onto the undirected node graph the pyramid
@@ -407,6 +559,73 @@ mod tests {
407
559
  assert_ne!(c0, c3);
408
560
  }
409
561
 
562
+ /// The base graph of `tests/fixtures/louvain-hangs/min-15-level0.nt`, on
563
+ /// which rete 0.3.3 looped forever, as (n, edges).
564
+ fn min_15_edges() -> (usize, Vec<(usize, usize, f64)>) {
565
+ let text = include_str!("../tests/fixtures/louvain-hangs/min-15-level0.nt");
566
+ let quads = crate::ingest::parse_statements(text, "nt").unwrap();
567
+ let mut db = DictionaryBuilder::new();
568
+ for (s, p, o, _) in &quads {
569
+ db.observe(s, p, o);
570
+ }
571
+ let dict = db.build();
572
+ let edges = quads
573
+ .iter()
574
+ .map(|(s, p, o, _)| {
575
+ let (s, _, o) = dict.encode(s, p, o).unwrap();
576
+ (
577
+ dict.subject_node(s) as usize,
578
+ dict.object_node(o) as usize,
579
+ 1.0,
580
+ )
581
+ })
582
+ .collect();
583
+ (dict.node_count() as usize, edges)
584
+ }
585
+
586
+ #[test]
587
+ fn the_gain_test_follows_the_weights() {
588
+ let (n, edges) = min_15_edges();
589
+ assert_eq!(Graph::from_edges(n, &edges).gain_test(), GainTest::ExactF64);
590
+ // (2m)² > 2^53: integers too big to cross-multiply in f64.
591
+ let big: Vec<_> = edges.iter().map(|&(u, v, _)| (u, v, 1e7)).collect();
592
+ assert_eq!(Graph::from_edges(n, &big).gain_test(), GainTest::ExactI128);
593
+ let half: Vec<_> = edges.iter().map(|&(u, v, _)| (u, v, 0.5)).collect();
594
+ assert_eq!(Graph::from_edges(n, &half).gain_test(), GainTest::Tolerant);
595
+ }
596
+
597
+ /// Modularity is invariant under scaling every weight, so all three paths
598
+ /// must find the same partition of the graph that used to hang, and none
599
+ /// may need the pass cap.
600
+ #[test]
601
+ fn every_gain_test_converges_on_the_old_hang_to_one_partition() {
602
+ let (n, edges) = min_15_edges();
603
+ let base = louvain_one_level_stats(&Graph::from_edges(n, &edges));
604
+ assert!(!base.1.capped);
605
+ for (scale, path) in [
606
+ (1e7, GainTest::ExactI128),
607
+ (0.5, GainTest::Tolerant),
608
+ (0.1, GainTest::Tolerant),
609
+ ] {
610
+ let scaled: Vec<_> = edges.iter().map(|&(u, v, w)| (u, v, w * scale)).collect();
611
+ let g = Graph::from_edges(n, &scaled);
612
+ assert_eq!(g.gain_test(), path);
613
+ let (p, st) = louvain_one_level_stats(&g);
614
+ assert!(!st.capped, "scale {scale}: capped");
615
+ assert_eq!(p, base.0, "scale {scale}: a different partition");
616
+ }
617
+ }
618
+
619
+ #[test]
620
+ fn levels_report_their_passes() {
621
+ let (p, st) = louvain_one_level_stats(&barbell());
622
+ assert_eq!(p.count, 2);
623
+ assert!(st.passes >= 2 && st.passes < MAX_LOUVAIN_PASSES, "{st:?}");
624
+ assert!(!st.capped);
625
+ let (_, st) = louvain_one_level_stats(&Graph::from_edges(3, &[]));
626
+ assert_eq!(st, LevelStats::default());
627
+ }
628
+
410
629
  /// Two fully-connected triples-clusters joined by one bridge edge, expressed
411
630
  /// as RDF `knows` triples, then projected and clustered.
412
631
  #[test]
@@ -0,0 +1,149 @@
1
+ # Louvain non-termination regression, reported by the Jev Games consumer
2
+ # (rete-graph 0.3.2 never returned from build; their fixture
3
+ # test/fixtures/rete-louvain-hangs.json, jev-games commit 8666728, "commander").
4
+ # 145 distinct triples, verbatim.
5
+ <urn:jev-games:cmdr:g> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Game> .
6
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#status> "playing" .
7
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#phase> "main1" .
8
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#turn> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
9
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#active> <urn:jev-games:cmdr:g/side/p2> .
10
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#toMove> <urn:jev-games:cmdr:g/side/p1> .
11
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#seatCount> "3"^^<http://www.w3.org/2001/XMLSchema#integer> .
12
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#firstTurn> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
13
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#stackCount> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
14
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#queueCount> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
15
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#commanderMoveCount> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
16
+ <urn:jev-games:cmdr:g> <https://jev-games.dev/ns/mtg-commander#startingLife> "40"^^<http://www.w3.org/2001/XMLSchema#integer> .
17
+ <urn:jev-games:cmdr:g/stack/0> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#StackObject> .
18
+ <urn:jev-games:cmdr:g/stack/0> <https://jev-games.dev/ns/mtg-commander#stackIndex> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
19
+ <urn:jev-games:cmdr:g/stack/0> <https://jev-games.dev/ns/mtg-commander#controller> <urn:jev-games:cmdr:g/side/p2> .
20
+ <urn:jev-games:cmdr:g/stack/0> <https://jev-games.dev/ns/mtg-commander#stackKind> "spell" .
21
+ <urn:jev-games:cmdr:g/stack/0> <https://jev-games.dev/ns/mtg-commander#source> <urn:jev-games:cmdr:g/card/sol-ring~p2-38> .
22
+ <urn:jev-games:cmdr:g/stack/0> <https://jev-games.dev/ns/mtg-commander#isTop> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
23
+ <urn:jev-games:cmdr:g/stack/0> <https://jev-games.dev/ns/mtg-commander#sourceName> "Sol Ring" .
24
+ <urn:jev-games:cmdr:g/side/p1> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Player> .
25
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#key> "p1" .
26
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#seat> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
27
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#life> "40"^^<http://www.w3.org/2001/XMLSchema#integer> .
28
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#out> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
29
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#drewFromEmpty> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
30
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#handCount> "7"^^<http://www.w3.org/2001/XMLSchema#integer> .
31
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#libraryCount> "92"^^<http://www.w3.org/2001/XMLSchema#integer> .
32
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#landsPlayed> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
33
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#extraLandDrops> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
34
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#identity> "G" .
35
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#identity> "U" .
36
+ <urn:jev-games:cmdr:g/side/p1> <https://jev-games.dev/ns/mtg-commander#identity> "W" .
37
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Card> .
38
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#cid> "tidus-yuna-s-guardian~p1-99" .
39
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#name> "Tidus, Yuna's Guardian" .
40
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#owner> <urn:jev-games:cmdr:g/side/p1> .
41
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#zone> "command" .
42
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#zoneOf> <urn:jev-games:cmdr:g/side/p1> .
43
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#isCommander> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
44
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#commanderCasts> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
45
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#type> "Creature" .
46
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#supertype> "Legendary" .
47
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Human" .
48
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Warrior" .
49
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#colour> "G" .
50
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#colour> "U" .
51
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#colour> "W" .
52
+ <urn:jev-games:cmdr:g/card/tidus-yuna-s-guardian~p1-99> <https://jev-games.dev/ns/mtg-commander#manaValue> "3"^^<http://www.w3.org/2001/XMLSchema#integer> .
53
+ <urn:jev-games:cmdr:g/side/p2> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Player> .
54
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#key> "p2" .
55
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#seat> "2"^^<http://www.w3.org/2001/XMLSchema#integer> .
56
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#life> "40"^^<http://www.w3.org/2001/XMLSchema#integer> .
57
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#out> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
58
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#drewFromEmpty> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
59
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#handCount> "6"^^<http://www.w3.org/2001/XMLSchema#integer> .
60
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#libraryCount> "91"^^<http://www.w3.org/2001/XMLSchema#integer> .
61
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#landsPlayed> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
62
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#extraLandDrops> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
63
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#identity> "B" .
64
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#identity> "U" .
65
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#identity> "W" .
66
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#turnCount> <urn:jev-games:cmdr:g/side/p2/turn/lands> .
67
+ <urn:jev-games:cmdr:g/side/p2/turn/lands> <https://jev-games.dev/ns/mtg-commander#turnKey> "lands" .
68
+ <urn:jev-games:cmdr:g/side/p2/turn/lands> <https://jev-games.dev/ns/mtg-commander#count> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
69
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#turnCount> <urn:jev-games:cmdr:g/side/p2/turn/entered> .
70
+ <urn:jev-games:cmdr:g/side/p2/turn/entered> <https://jev-games.dev/ns/mtg-commander#turnKey> "entered" .
71
+ <urn:jev-games:cmdr:g/side/p2/turn/entered> <https://jev-games.dev/ns/mtg-commander#count> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
72
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#turnCount> <urn:jev-games:cmdr:g/side/p2/turn/spent> .
73
+ <urn:jev-games:cmdr:g/side/p2/turn/spent> <https://jev-games.dev/ns/mtg-commander#turnKey> "spent" .
74
+ <urn:jev-games:cmdr:g/side/p2/turn/spent> <https://jev-games.dev/ns/mtg-commander#count> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
75
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#turnCount> <urn:jev-games:cmdr:g/side/p2/turn/spells> .
76
+ <urn:jev-games:cmdr:g/side/p2/turn/spells> <https://jev-games.dev/ns/mtg-commander#turnKey> "spells" .
77
+ <urn:jev-games:cmdr:g/side/p2/turn/spells> <https://jev-games.dev/ns/mtg-commander#count> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
78
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#turnCount> <urn:jev-games:cmdr:g/side/p2/turn/castIds> .
79
+ <urn:jev-games:cmdr:g/side/p2/turn/castIds> <https://jev-games.dev/ns/mtg-commander#turnKey> "castIds" .
80
+ <urn:jev-games:cmdr:g/side/p2/turn/castIds> <https://jev-games.dev/ns/mtg-commander#count> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
81
+ <urn:jev-games:cmdr:g/side/p2> <https://jev-games.dev/ns/mtg-commander#turnCount> <urn:jev-games:cmdr:g/side/p2/turn/noncreature> .
82
+ <urn:jev-games:cmdr:g/side/p2/turn/noncreature> <https://jev-games.dev/ns/mtg-commander#turnKey> "noncreature" .
83
+ <urn:jev-games:cmdr:g/side/p2/turn/noncreature> <https://jev-games.dev/ns/mtg-commander#count> "1"^^<http://www.w3.org/2001/XMLSchema#integer> .
84
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Card> .
85
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#cid> "y-shtola-night-s-blessed~p2-99" .
86
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#name> "Y'shtola, Night's Blessed" .
87
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#owner> <urn:jev-games:cmdr:g/side/p2> .
88
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#zone> "command" .
89
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#zoneOf> <urn:jev-games:cmdr:g/side/p2> .
90
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#isCommander> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
91
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#commanderCasts> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
92
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#type> "Creature" .
93
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#supertype> "Legendary" .
94
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Cat" .
95
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Warlock" .
96
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#colour> "B" .
97
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#colour> "U" .
98
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#colour> "W" .
99
+ <urn:jev-games:cmdr:g/card/y-shtola-night-s-blessed~p2-99> <https://jev-games.dev/ns/mtg-commander#manaValue> "4"^^<http://www.w3.org/2001/XMLSchema#integer> .
100
+ <urn:jev-games:cmdr:g/side/p3> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Player> .
101
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#key> "p3" .
102
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#seat> "3"^^<http://www.w3.org/2001/XMLSchema#integer> .
103
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#life> "40"^^<http://www.w3.org/2001/XMLSchema#integer> .
104
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#out> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
105
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#drewFromEmpty> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
106
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#handCount> "6"^^<http://www.w3.org/2001/XMLSchema#integer> .
107
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#libraryCount> "93"^^<http://www.w3.org/2001/XMLSchema#integer> .
108
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#landsPlayed> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
109
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#extraLandDrops> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
110
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#identity> "B" .
111
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#identity> "R" .
112
+ <urn:jev-games:cmdr:g/side/p3> <https://jev-games.dev/ns/mtg-commander#identity> "W" .
113
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Card> .
114
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#cid> "terra-herald-of-hope~p3-99" .
115
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#name> "Terra, Herald of Hope" .
116
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#owner> <urn:jev-games:cmdr:g/side/p3> .
117
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#zone> "command" .
118
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#zoneOf> <urn:jev-games:cmdr:g/side/p3> .
119
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#isCommander> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
120
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#commanderCasts> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
121
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#type> "Creature" .
122
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#supertype> "Legendary" .
123
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Human" .
124
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Wizard" .
125
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#subtype> "Warrior" .
126
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#colour> "B" .
127
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#colour> "R" .
128
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#colour> "W" .
129
+ <urn:jev-games:cmdr:g/card/terra-herald-of-hope~p3-99> <https://jev-games.dev/ns/mtg-commander#manaValue> "3"^^<http://www.w3.org/2001/XMLSchema#integer> .
130
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#Card> .
131
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#cid> "sunken-ruins~p2-29" .
132
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#name> "Sunken Ruins" .
133
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#owner> <urn:jev-games:cmdr:g/side/p2> .
134
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#zone> "battlefield" .
135
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#zoneOf> <urn:jev-games:cmdr:g/side/p2> .
136
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#type> "Land" .
137
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#manaValue> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
138
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#controller> <urn:jev-games:cmdr:g/side/p2> .
139
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#battlefieldOrder> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
140
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#boardOrder> "2000"^^<http://www.w3.org/2001/XMLSchema#integer> .
141
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#tapped> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
142
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#sick> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
143
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#damage> "0"^^<http://www.w3.org/2001/XMLSchema#integer> .
144
+ <urn:jev-games:cmdr:g/card/sunken-ruins~p2-29> <https://jev-games.dev/ns/mtg-commander#enteredThisTurn> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
145
+ <https://jev-games.dev/ns/mtg-commander#CommanderRules> <https://jev-games.dev/ns/mtg-commander#commanderDamageToLose> "21"^^<http://www.w3.org/2001/XMLSchema#integer> .
146
+ <https://jev-games.dev/ns/mtg-commander#CommanderRules> <https://jev-games.dev/ns/mtg-commander#handSize> "7"^^<http://www.w3.org/2001/XMLSchema#integer> .
147
+ <https://jev-games.dev/ns/mtg-commander#CommanderRules> <https://jev-games.dev/ns/mtg-commander#taxPerCast> "2"^^<http://www.w3.org/2001/XMLSchema#integer> .
148
+ <urn:jev-games:cmdr:g/step/9> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://jev-games.dev/ns/mtg-commander#CommanderStep> .
149
+ <urn:jev-games:cmdr:g/step/9> <https://jev-games.dev/ns#actor> <urn:jev-games:cmdr:g/side/p1> .
@@ -0,0 +1,34 @@
1
+ # Louvain non-termination regression, reported by the Jev Games consumer
2
+ # (rete-graph 0.3.2 never returned from build; their fixture
3
+ # test/fixtures/rete-louvain-hangs.json, jev-games commit 8666728, "random"[0]).
4
+ # 30 distinct triples, verbatim.
5
+ <urn:n14> <urn:p0> "1" .
6
+ <urn:n4> <urn:p1> <urn:n10> .
7
+ <urn:n13> <urn:p0> <urn:n3> .
8
+ <urn:n13> <urn:p2> "4" .
9
+ <urn:n18> <urn:p2> "3" .
10
+ <urn:n17> <urn:p2> "0" .
11
+ <urn:n18> <urn:p1> "3" .
12
+ <urn:n13> <urn:p2> <urn:n11> .
13
+ <urn:n18> <urn:p1> <urn:n21> .
14
+ <urn:n8> <urn:p1> <urn:n22> .
15
+ <urn:n13> <urn:p2> "2" .
16
+ <urn:n14> <urn:p2> "2" .
17
+ <urn:n9> <urn:p1> <urn:n6> .
18
+ <urn:n2> <urn:p1> <urn:n4> .
19
+ <urn:n15> <urn:p1> <urn:n18> .
20
+ <urn:n12> <urn:p2> <urn:n20> .
21
+ <urn:n5> <urn:p0> "4" .
22
+ <urn:n18> <urn:p1> "0" .
23
+ <urn:n4> <urn:p0> <urn:n18> .
24
+ <urn:n12> <urn:p1> <urn:n17> .
25
+ <urn:n15> <urn:p2> <urn:n10> .
26
+ <urn:n21> <urn:p1> <urn:n1> .
27
+ <urn:n15> <urn:p2> "0" .
28
+ <urn:n3> <urn:p2> "3" .
29
+ <urn:n16> <urn:p0> <urn:n5> .
30
+ <urn:n8> <urn:p0> "0" .
31
+ <urn:n22> <urn:p2> <urn:n13> .
32
+ <urn:n21> <urn:p1> <urn:n15> .
33
+ <urn:n9> <urn:p1> <urn:n7> .
34
+ <urn:n16> <urn:p0> "3" .
@@ -0,0 +1,67 @@
1
+ # Louvain non-termination regression, reported by the Jev Games consumer
2
+ # (rete-graph 0.3.2 never returned from build; their fixture
3
+ # test/fixtures/rete-louvain-hangs.json, jev-games commit 8666728, "random"[1]).
4
+ # 63 distinct triples, verbatim.
5
+ <urn:n11> <urn:p2> <urn:n15> .
6
+ <urn:n19> <urn:p1> <urn:n1> .
7
+ <urn:n10> <urn:p0> "3" .
8
+ <urn:n6> <urn:p1> <urn:n4> .
9
+ <urn:n10> <urn:p0> "1" .
10
+ <urn:n4> <urn:p1> "2" .
11
+ <urn:n9> <urn:p1> "3" .
12
+ <urn:n7> <urn:p0> "0" .
13
+ <urn:n18> <urn:p2> <urn:n15> .
14
+ <urn:n12> <urn:p2> "3" .
15
+ <urn:n2> <urn:p0> <urn:n9> .
16
+ <urn:n12> <urn:p1> "2" .
17
+ <urn:n11> <urn:p2> "1" .
18
+ <urn:n5> <urn:p0> <urn:n14> .
19
+ <urn:n13> <urn:p0> <urn:n1> .
20
+ <urn:n15> <urn:p1> <urn:n7> .
21
+ <urn:n6> <urn:p0> <urn:n16> .
22
+ <urn:n2> <urn:p2> "2" .
23
+ <urn:n7> <urn:p1> <urn:n4> .
24
+ <urn:n8> <urn:p0> <urn:n2> .
25
+ <urn:n14> <urn:p0> <urn:n10> .
26
+ <urn:n7> <urn:p1> <urn:n12> .
27
+ <urn:n5> <urn:p0> <urn:n17> .
28
+ <urn:n4> <urn:p0> <urn:n13> .
29
+ <urn:n0> <urn:p1> "3" .
30
+ <urn:n8> <urn:p2> <urn:n7> .
31
+ <urn:n6> <urn:p0> "1" .
32
+ <urn:n15> <urn:p0> "4" .
33
+ <urn:n2> <urn:p1> <urn:n10> .
34
+ <urn:n16> <urn:p1> <urn:n19> .
35
+ <urn:n11> <urn:p0> <urn:n13> .
36
+ <urn:n0> <urn:p2> <urn:n17> .
37
+ <urn:n18> <urn:p2> <urn:n12> .
38
+ <urn:n14> <urn:p0> <urn:n3> .
39
+ <urn:n5> <urn:p0> <urn:n19> .
40
+ <urn:n13> <urn:p2> "0" .
41
+ <urn:n13> <urn:p2> "3" .
42
+ <urn:n13> <urn:p0> "0" .
43
+ <urn:n3> <urn:p2> <urn:n4> .
44
+ <urn:n12> <urn:p0> "3" .
45
+ <urn:n2> <urn:p2> <urn:n0> .
46
+ <urn:n16> <urn:p0> "3" .
47
+ <urn:n12> <urn:p2> <urn:n7> .
48
+ <urn:n4> <urn:p2> <urn:n9> .
49
+ <urn:n0> <urn:p1> <urn:n1> .
50
+ <urn:n9> <urn:p1> <urn:n19> .
51
+ <urn:n19> <urn:p0> <urn:n19> .
52
+ <urn:n14> <urn:p0> <urn:n9> .
53
+ <urn:n18> <urn:p2> "4" .
54
+ <urn:n9> <urn:p0> <urn:n3> .
55
+ <urn:n1> <urn:p2> <urn:n17> .
56
+ <urn:n11> <urn:p1> "3" .
57
+ <urn:n6> <urn:p1> <urn:n14> .
58
+ <urn:g> <urn:g> "0" .
59
+ <urn:g> <urn:g> "1" .
60
+ <urn:g> <urn:g> "2" .
61
+ <urn:g> <urn:g> "3" .
62
+ <urn:g> <urn:g> "4" .
63
+ <urn:g> <urn:g> "5" .
64
+ <urn:g> <urn:g> "6" .
65
+ <urn:g> <urn:g> "7" .
66
+ <urn:g> <urn:g> "8" .
67
+ <urn:g> <urn:g> "9" .
@@ -0,0 +1,20 @@
1
+ # Louvain non-termination regression (rete 0.3.3 and earlier never return from
2
+ # build on this graph). 15 triples, 7 nodes, so 2m = 30: two nodes swap between
3
+ # two communities whose gains are exactly equal (10/30 and 12/30) but round one
4
+ # ulp apart in floating point. The smallest hang found: none at <= 13 triples in
5
+ # 100M random graphs. Found by dev/louvain-hang (seed 2959679, LH_MAXT=26 LH_MAXN=20).
6
+ <urn:n0> <urn:p1> <urn:n0> .
7
+ <urn:n1> <urn:p1> "1" .
8
+ <urn:n1> <urn:p1> <urn:n0> .
9
+ <urn:n0> <urn:p1> <urn:n1> .
10
+ <urn:n0> <urn:p0> <urn:n1> .
11
+ <urn:n1> <urn:p0> <urn:n1> .
12
+ <urn:n2> <urn:p0> <urn:n0> .
13
+ <urn:n1> <urn:p1> "0" .
14
+ <urn:n0> <urn:p1> "0" .
15
+ <urn:n0> <urn:p0> "1" .
16
+ <urn:n0> <urn:p0> <urn:n0> .
17
+ <urn:n2> <urn:p1> <urn:n1> .
18
+ <urn:n2> <urn:p1> "2" .
19
+ <urn:n2> <urn:p0> "3" .
20
+ <urn:n1> <urn:p1> "3" .
@@ -0,0 +1,21 @@
1
+ # Louvain non-termination regression: 18 triples, a hang at level 1 (the
2
+ # aggregated graph, whose self-loops and summed weights are still integers).
3
+ # Found by dev/louvain-hang (seed 20519787, LH_MAXT=26 LH_MAXN=20).
4
+ <urn:n1> <urn:p0> <urn:n5> .
5
+ <urn:n7> <urn:p0> "0" .
6
+ <urn:n9> <urn:p0> "0" .
7
+ <urn:n11> <urn:p0> "0" .
8
+ <urn:n4> <urn:p0> "0" .
9
+ <urn:n6> <urn:p0> "0" .
10
+ <urn:n0> <urn:p0> "0" .
11
+ <urn:n0> <urn:p0> <urn:n1> .
12
+ <urn:n9> <urn:p0> <urn:n7> .
13
+ <urn:n8> <urn:p0> "0" .
14
+ <urn:n7> <urn:p0> <urn:n3> .
15
+ <urn:n8> <urn:p0> <urn:n3> .
16
+ <urn:n2> <urn:p0> "0" .
17
+ <urn:n0> <urn:p0> <urn:n10> .
18
+ <urn:n10> <urn:p0> "0" .
19
+ <urn:n6> <urn:p0> <urn:n9> .
20
+ <urn:n3> <urn:p0> "0" .
21
+ <urn:n2> <urn:p0> <urn:n7> .
@@ -0,0 +1,155 @@
1
+ //! Louvain must terminate: graphs on which `build` never returned in rete 0.3.3
2
+ //! and earlier (see `pyramid::louvain_one_level`). Every level of every
3
+ //! fixture must converge on its own, well inside `MAX_LOUVAIN_PASSES`, and
4
+ //! the whole build must return. Each check runs on a worker thread under a
5
+ //! time limit, so a regression fails here instead of hanging the suite.
6
+ //!
7
+ //! Fixtures (`tests/fixtures/louvain-hangs/`):
8
+ //! - `min-15-level0.nt`, `min-18-level1.nt`: the smallest hangs found by the
9
+ //! random-graph harness, one in the base graph and one in the aggregated
10
+ //! level above it;
11
+ //! - `jev-*.nt`: reported by the Jev Games consumer (a 145-triple Commander
12
+ //! position and two random graphs of 30 and 63 triples).
13
+
14
+ use std::sync::mpsc;
15
+ use std::time::Duration;
16
+
17
+ use rete_core::ingest::{assemble_dataset, parse_statements};
18
+ use rete_core::{
19
+ build_dendrogram, louvain_one_level_stats, project_graph, DictionaryBuilder, MAX_LOUVAIN_PASSES,
20
+ };
21
+
22
+ const FIXTURES: &[(&str, &str)] = &[
23
+ (
24
+ "min-15-level0",
25
+ include_str!("fixtures/louvain-hangs/min-15-level0.nt"),
26
+ ),
27
+ (
28
+ "min-18-level1",
29
+ include_str!("fixtures/louvain-hangs/min-18-level1.nt"),
30
+ ),
31
+ (
32
+ "jev-commander-145",
33
+ include_str!("fixtures/louvain-hangs/jev-commander-145.nt"),
34
+ ),
35
+ (
36
+ "jev-random30-30",
37
+ include_str!("fixtures/louvain-hangs/jev-random30-30.nt"),
38
+ ),
39
+ (
40
+ "jev-random63-63",
41
+ include_str!("fixtures/louvain-hangs/jev-random63-63.nt"),
42
+ ),
43
+ ];
44
+
45
+ /// Run `f` on a worker thread; fail if it has not returned within `secs`.
46
+ fn within<T: Send + 'static>(what: &str, secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
47
+ let (tx, rx) = mpsc::channel();
48
+ std::thread::spawn(move || {
49
+ tx.send(f()).ok();
50
+ });
51
+ rx.recv_timeout(Duration::from_secs(secs))
52
+ .unwrap_or_else(|_| panic!("{what}: did not return within {secs} s"))
53
+ }
54
+
55
+ /// Per level: (passes, capped, communities), the base graph first.
56
+ fn levels(text: &str) -> Vec<(usize, bool, usize)> {
57
+ let quads = parse_statements(text, "nt").expect("fixture parses");
58
+ let mut db = DictionaryBuilder::new();
59
+ for (s, p, o, _) in &quads {
60
+ db.observe(s, p, o);
61
+ }
62
+ let dict = db.build();
63
+ let triples: Vec<_> = quads
64
+ .iter()
65
+ .map(|(s, p, o, _)| dict.encode(s, p, o).unwrap())
66
+ .collect();
67
+ // The loop of `build_dendrogram`, keeping each level's stats.
68
+ let mut g = project_graph(&dict, &triples);
69
+ let mut out = Vec::new();
70
+ loop {
71
+ let (p, st) = louvain_one_level_stats(&g);
72
+ out.push((st.passes, st.capped, p.count));
73
+ if p.count >= g.node_count() {
74
+ break;
75
+ }
76
+ let next = g.quotient(&p.comm, p.count);
77
+ if next.node_count() <= 1 {
78
+ break;
79
+ }
80
+ g = next;
81
+ }
82
+ out
83
+ }
84
+
85
+ #[test]
86
+ fn every_level_converges_without_the_cap() {
87
+ for &(name, text) in FIXTURES {
88
+ let lv = within(name, 30, move || levels(text));
89
+ assert!(
90
+ lv.len() >= 2,
91
+ "{name}: expected at least two levels, got {lv:?}"
92
+ );
93
+ for (i, &(passes, capped, _)) in lv.iter().enumerate() {
94
+ assert!(!capped, "{name}: level {i} stopped at the pass cap: {lv:?}");
95
+ // Converged levels need a handful of passes; the cap is a backstop.
96
+ assert!(
97
+ passes <= 50,
98
+ "{name}: level {i} took {passes} passes: {lv:?}"
99
+ );
100
+ assert!(passes < MAX_LOUVAIN_PASSES);
101
+ }
102
+ }
103
+ }
104
+
105
+ #[test]
106
+ fn the_whole_build_returns() {
107
+ for &(name, text) in FIXTURES {
108
+ let n = within(name, 60, move || {
109
+ let quads = parse_statements(text, "nt").unwrap();
110
+ let n = quads.len();
111
+ let (bytes, stats) = assemble_dataset(quads, &[]);
112
+ assert!(stats.pyramid_levels > 0, "a pyramid was built");
113
+ assert!(!bytes.is_empty());
114
+ n
115
+ });
116
+ assert!(n >= 15, "{name}: {n} statements");
117
+ }
118
+ }
119
+
120
+ /// The partition is a pure function of the input: two builds of the same text
121
+ /// are byte-identical (the pyramid included), and the smallest fixture's
122
+ /// levels are pinned, so a change in a tie decision shows up here. The wasm
123
+ /// test (`crates/rete-wasm/tests/louvain_termination.rs`) pins the same file.
124
+ #[test]
125
+ fn the_fixtures_build_reproducibly() {
126
+ for &(name, text) in FIXTURES {
127
+ let build = move || assemble_dataset(parse_statements(text, "nt").unwrap(), &[]).0;
128
+ let a = within(name, 60, build);
129
+ let b = within(name, 60, build);
130
+ assert_eq!(a, b, "{name}: two builds differ");
131
+ }
132
+ assert_eq!(
133
+ dendrogram(FIXTURES[0].1),
134
+ PINNED_MIN_15,
135
+ "min-15-level0: the dendrogram (community per node, per level)"
136
+ );
137
+ }
138
+
139
+ fn dendrogram(text: &str) -> Vec<Vec<usize>> {
140
+ let quads = parse_statements(text, "nt").unwrap();
141
+ let mut db = DictionaryBuilder::new();
142
+ for (s, p, o, _) in &quads {
143
+ db.observe(s, p, o);
144
+ }
145
+ let dict = db.build();
146
+ let triples: Vec<_> = quads
147
+ .iter()
148
+ .map(|(s, p, o, _)| dict.encode(s, p, o).unwrap())
149
+ .collect();
150
+ let d = build_dendrogram(&project_graph(&dict, &triples));
151
+ d.levels.into_iter().map(|p| p.comm).collect()
152
+ }
153
+
154
+ /// `dendrogram(min-15-level0.nt)` with the exact move test.
155
+ const PINNED_MIN_15: &[&[usize]] = &[&[0, 0, 1, 0, 0, 1, 1]];
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rete-graph"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Query local and remote .rete graph files with SPARQL — Python bindings for the rete engine."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes