rete-graph 0.3.0__tar.gz → 0.3.2__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 (76) hide show
  1. {rete_graph-0.3.0 → rete_graph-0.3.2}/Cargo.toml +1 -1
  2. {rete_graph-0.3.0 → rete_graph-0.3.2}/PKG-INFO +31 -1
  3. {rete_graph-0.3.0/clients/python → rete_graph-0.3.2}/README.md +30 -0
  4. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/Cargo.lock +2 -2
  5. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/Cargo.toml +1 -1
  6. {rete_graph-0.3.0 → rete_graph-0.3.2/clients/python}/README.md +30 -0
  7. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/src/lib.rs +168 -7
  8. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/tests/conftest.py +56 -0
  9. rete_graph-0.3.2/clients/python/tests/test_rete_graph.py +575 -0
  10. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/dict.rs +2 -7
  11. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/extbuild.rs +0 -1
  12. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/file.rs +26 -32
  13. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/index.rs +10 -6
  14. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql/aggregate.rs +67 -13
  15. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql/lower.rs +67 -0
  16. {rete_graph-0.3.0 → rete_graph-0.3.2}/pyproject.toml +1 -1
  17. {rete_graph-0.3.0 → rete_graph-0.3.2}/python/rete_graph/__init__.py +161 -2
  18. rete_graph-0.3.0/clients/python/tests/test_rete_graph.py +0 -308
  19. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/.gitignore +0 -0
  20. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/agents/README.md +0 -0
  21. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/agents/langchain_agent.py +0 -0
  22. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/agents/pydantic_ai_agent.py +0 -0
  23. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/agents/rete_tools.py +0 -0
  24. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/jupyterlite-build-tutorial.ipynb +0 -0
  25. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/jupyterlite-demo.ipynb +0 -0
  26. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/examples/tutorial.ipynb +0 -0
  27. {rete_graph-0.3.0 → rete_graph-0.3.2}/clients/python/src/readers.rs +0 -0
  28. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/Cargo.toml +0 -0
  29. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/LICENSE +0 -0
  30. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/README.md +0 -0
  31. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/examples/coherence_bench.rs +0 -0
  32. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/examples/parallel_bench.rs +0 -0
  33. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/examples/qbench.rs +0 -0
  34. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/bgp.rs +0 -0
  35. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/block_cache.rs +0 -0
  36. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/dictionary.rs +0 -0
  37. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/geo.rs +0 -0
  38. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/geo3.rs +0 -0
  39. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/header.rs +0 -0
  40. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/ingest.rs +0 -0
  41. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/lib.rs +0 -0
  42. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/meta.rs +0 -0
  43. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/parallel.rs +0 -0
  44. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/pyramid.rs +0 -0
  45. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/reach.rs +0 -0
  46. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/reader.rs +0 -0
  47. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/reason.rs +0 -0
  48. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/results.rs +0 -0
  49. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/row.rs +0 -0
  50. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/schema_pyramid.rs +0 -0
  51. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/service.rs +0 -0
  52. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/shacl.rs +0 -0
  53. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql/eval.rs +0 -0
  54. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql/expr.rs +0 -0
  55. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql/path.rs +0 -0
  56. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql/ql.rs +0 -0
  57. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/sparql.rs +0 -0
  58. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/terms.rs +0 -0
  59. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/text_index.rs +0 -0
  60. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/tiling.rs +0 -0
  61. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/triples.rs +0 -0
  62. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/src/varint.rs +0 -0
  63. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/community_eval.rs +0 -0
  64. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/compatibility_v1.rs +0 -0
  65. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/fixtures/v1/minimal.rete +0 -0
  66. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/fixtures/v1/source.nq +0 -0
  67. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/properties.proptest-regressions +0 -0
  68. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/properties.rs +0 -0
  69. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/public_api.rs +0 -0
  70. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/ranged.rs +0 -0
  71. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/robustness.rs +0 -0
  72. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/roundtrip.rs +0 -0
  73. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/service_federation.rs +0 -0
  74. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/shacl_core.rs +0 -0
  75. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/sparql_integration.rs +0 -0
  76. {rete_graph-0.3.0 → rete_graph-0.3.2}/crates/rete-core/tests/sparql_nondeterministic.rs +0 -0
@@ -17,7 +17,7 @@ members = [
17
17
  exclude = ["fuzz", "clients/python", "clients/r/src/rust", "clients/java/ffi"]
18
18
 
19
19
  [workspace.package]
20
- version = "0.3.0"
20
+ version = "0.3.2"
21
21
  edition = "2021"
22
22
  license = "Apache-2.0"
23
23
  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.0
3
+ Version: 0.3.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -98,6 +98,36 @@ g.info(), g.quads, g.content_hash()
98
98
  SPARQL 1.1 `SERVICE` federation works out of the box — join a `.rete` file
99
99
  against any public SPARQL endpoint in one query.
100
100
 
101
+ ### Stream every quad out
102
+
103
+ `iter_quads()` is a generator over the whole dataset — default graph first,
104
+ then each named graph — walked in batches, so **peak memory is bounded by one
105
+ batch no matter how big the file is**:
106
+
107
+ ```python
108
+ for s, p, o, graph in g.iter_quads(): # N-Triples term tokens; graph is
109
+ ... # None for the default graph
110
+ ```
111
+
112
+ `to_nquads()` serializes the same walk to a path or any open stream, which is
113
+ what makes handing a `.rete` to another RDF toolchain a three-liner:
114
+
115
+ ```python
116
+ g.to_nquads("out.nq") # or a StringIO / gzip.open(...) / sys.stdout
117
+ store = pyoxigraph.Store()
118
+ store.bulk_load(path="out.nq", format=pyoxigraph.RdfFormat.N_QUADS)
119
+ ```
120
+
121
+ Measured on `figshare.rete` (221 MB, 15.9 M quads): `to_nquads` streams the
122
+ whole file in **941 MiB peak RSS in 25 s**, of which only ~20 MiB is the batch
123
+ — the rest is the engine's faulted index/dictionary, and it is flat in the
124
+ quad count. `list(g.iter_quads())` on the same file needs many GB, and
125
+ `rete export` needs > 1.1 GiB. Pass `batch_size=` to trade: a small batch
126
+ makes a *peek* at a remote graph much cheaper (5 quads for 1.1 MB of range
127
+ reads at `batch_size=1`, versus 44 MB at the default 10 000), a large one
128
+ speeds up a full walk. Scope with `graph=`: `rete.DEFAULT_GRAPH` for the
129
+ unnamed default graph alone, or an IRI for one named graph.
130
+
101
131
  ### Prepare a `.rete` step by step
102
132
 
103
133
  The lazy `Builder` configures a build — sources, embedded Dataset Card,
@@ -70,6 +70,36 @@ g.info(), g.quads, g.content_hash()
70
70
  SPARQL 1.1 `SERVICE` federation works out of the box — join a `.rete` file
71
71
  against any public SPARQL endpoint in one query.
72
72
 
73
+ ### Stream every quad out
74
+
75
+ `iter_quads()` is a generator over the whole dataset — default graph first,
76
+ then each named graph — walked in batches, so **peak memory is bounded by one
77
+ batch no matter how big the file is**:
78
+
79
+ ```python
80
+ for s, p, o, graph in g.iter_quads(): # N-Triples term tokens; graph is
81
+ ... # None for the default graph
82
+ ```
83
+
84
+ `to_nquads()` serializes the same walk to a path or any open stream, which is
85
+ what makes handing a `.rete` to another RDF toolchain a three-liner:
86
+
87
+ ```python
88
+ g.to_nquads("out.nq") # or a StringIO / gzip.open(...) / sys.stdout
89
+ store = pyoxigraph.Store()
90
+ store.bulk_load(path="out.nq", format=pyoxigraph.RdfFormat.N_QUADS)
91
+ ```
92
+
93
+ Measured on `figshare.rete` (221 MB, 15.9 M quads): `to_nquads` streams the
94
+ whole file in **941 MiB peak RSS in 25 s**, of which only ~20 MiB is the batch
95
+ — the rest is the engine's faulted index/dictionary, and it is flat in the
96
+ quad count. `list(g.iter_quads())` on the same file needs many GB, and
97
+ `rete export` needs > 1.1 GiB. Pass `batch_size=` to trade: a small batch
98
+ makes a *peek* at a remote graph much cheaper (5 quads for 1.1 MB of range
99
+ reads at `batch_size=1`, versus 44 MB at the default 10 000), a large one
100
+ speeds up a full walk. Scope with `graph=`: `rete.DEFAULT_GRAPH` for the
101
+ unnamed default graph alone, or an IRI for one named graph.
102
+
73
103
  ### Prepare a `.rete` step by step
74
104
 
75
105
  The lazy `Builder` configures a build — sources, embedded Dataset Card,
@@ -602,7 +602,7 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
602
602
 
603
603
  [[package]]
604
604
  name = "rete-core"
605
- version = "0.3.0"
605
+ version = "0.3.2"
606
606
  dependencies = [
607
607
  "blake3",
608
608
  "getrandom 0.2.17",
@@ -622,7 +622,7 @@ dependencies = [
622
622
 
623
623
  [[package]]
624
624
  name = "rete-python"
625
- version = "0.3.0"
625
+ version = "0.3.2"
626
626
  dependencies = [
627
627
  "pyo3",
628
628
  "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.0"
9
+ version = "0.3.2"
10
10
  edition = "2021"
11
11
  license = "Apache-2.0"
12
12
  repository = "https://github.com/caviri/rete"
@@ -70,6 +70,36 @@ g.info(), g.quads, g.content_hash()
70
70
  SPARQL 1.1 `SERVICE` federation works out of the box — join a `.rete` file
71
71
  against any public SPARQL endpoint in one query.
72
72
 
73
+ ### Stream every quad out
74
+
75
+ `iter_quads()` is a generator over the whole dataset — default graph first,
76
+ then each named graph — walked in batches, so **peak memory is bounded by one
77
+ batch no matter how big the file is**:
78
+
79
+ ```python
80
+ for s, p, o, graph in g.iter_quads(): # N-Triples term tokens; graph is
81
+ ... # None for the default graph
82
+ ```
83
+
84
+ `to_nquads()` serializes the same walk to a path or any open stream, which is
85
+ what makes handing a `.rete` to another RDF toolchain a three-liner:
86
+
87
+ ```python
88
+ g.to_nquads("out.nq") # or a StringIO / gzip.open(...) / sys.stdout
89
+ store = pyoxigraph.Store()
90
+ store.bulk_load(path="out.nq", format=pyoxigraph.RdfFormat.N_QUADS)
91
+ ```
92
+
93
+ Measured on `figshare.rete` (221 MB, 15.9 M quads): `to_nquads` streams the
94
+ whole file in **941 MiB peak RSS in 25 s**, of which only ~20 MiB is the batch
95
+ — the rest is the engine's faulted index/dictionary, and it is flat in the
96
+ quad count. `list(g.iter_quads())` on the same file needs many GB, and
97
+ `rete export` needs > 1.1 GiB. Pass `batch_size=` to trade: a small batch
98
+ makes a *peek* at a remote graph much cheaper (5 quads for 1.1 MB of range
99
+ reads at `batch_size=1`, versus 44 MB at the default 10 000), a large one
100
+ speeds up a full walk. Scope with `graph=`: `rete.DEFAULT_GRAPH` for the
101
+ unnamed default graph alone, or an IRI for one named graph.
102
+
73
103
  ### Prepare a `.rete` step by step
74
104
 
75
105
  The lazy `Builder` configures a build — sources, embedded Dataset Card,
@@ -18,8 +18,8 @@ use pyo3::prelude::*;
18
18
  use pyo3::types::PyBytes;
19
19
  use rete_core::{
20
20
  eval_query, eval_query_reasoned, results_envelope_json, schema_classes, schema_summary,
21
- validate_shacl, BlockCacheReader, CountingReader, DataGraph, RangeReader, Rete, ReteGraph,
22
- ShaclShapes, DEFAULT_BLOCK,
21
+ validate_shacl, BlockCacheReader, CountingReader, DataGraph, IndexPermutation, RangeReader,
22
+ Rete, ReteGraph, ShaclShapes, DEFAULT_BLOCK,
23
23
  };
24
24
  #[cfg(not(target_os = "emscripten"))]
25
25
  use rete_core::{parse_sparql_json_results, Binding, ServiceClient};
@@ -35,6 +35,18 @@ const JSON_SCHEMA_VERSION: u8 = 1;
35
35
  #[cfg(not(target_os = "emscripten"))]
36
36
  const MAX_SERVICE_RESPONSE: u64 = 256 * 1024 * 1024;
37
37
 
38
+ /// Quads resolved per [`Graph::dump_batch`] call — the memory bound of the
39
+ /// whole lazy dump path (`Graph.iter_quads` / `Graph.to_nquads` upstream).
40
+ /// 10 000 triples of typical terms is a few MB of Python objects in flight:
41
+ /// small enough to be noise beside the interpreter, large enough that the
42
+ /// per-call cost (one GIL round trip, one coalesced dictionary prefetch)
43
+ /// amortizes to nothing. The caller may override it.
44
+ const DUMP_BATCH: usize = 10_000;
45
+
46
+ /// One [`Graph::dump_batch`] answer: the batch's `(s, p, o)` term tokens, the
47
+ /// cursor to resume from, and whether the graph is exhausted.
48
+ type DumpBatch = (Vec<(String, String, String)>, u32, bool);
49
+
38
50
  /// `SERVICE <endpoint> { … }` transport: SPARQL Protocol over blocking HTTP,
39
51
  /// the native twin of the CLI's `HttpServiceClient`. Native-only — a SERVICE
40
52
  /// block on the Pyodide build reports the engine's no-client error.
@@ -136,6 +148,130 @@ impl Graph {
136
148
  fn fresh_verdict(&self) {
137
149
  self.rete.reset_load_failures();
138
150
  }
151
+
152
+ /// Resolve one bounded slice of a graph's triples — the engine-side half of
153
+ /// the lazy dump. Returns `(triples, next_cursor, done)`.
154
+ ///
155
+ /// The cursor is a **subject id**, and that is the whole trick. The engine's
156
+ /// constant-memory walk (`Rete::dump_each`) is *push*-based: it drives the
157
+ /// scan itself and hands each triple to a callback. Python wants a *pull*
158
+ /// API (a generator), and inverting push into pull needs the scan's stack
159
+ /// held between calls — either a thread (impossible on the Pyodide wheel,
160
+ /// which has no threads) or a self-referential `#[pyclass]` holding an
161
+ /// iterator that borrows the `Rete` it is stored next to (`unsafe`, plus an
162
+ /// `Arc` refactor of this struct). Both are a lot of machinery for a dump.
163
+ ///
164
+ /// So instead the scan is made *resumable*: SPO tiles are ordered by
165
+ /// subject id, so "all triples of subject `sid`, for `sid` ascending" visits
166
+ /// exactly the same tiles in exactly the same order as the full scan, and
167
+ /// the entire resume state collapses to one `u32` the caller keeps. No
168
+ /// thread, no `unsafe`, no borrow held across calls, works identically on
169
+ /// the Pyodide build — and unlike a `(offset, limit)` API it is O(n) overall
170
+ /// rather than O(n²/limit), because nothing is ever re-scanned.
171
+ ///
172
+ /// Memory is bounded by `max_quads` (plus, at most, one subject's fan-out —
173
+ /// a batch is cut at a subject boundary, never inside one). Work per call is
174
+ /// bounded too, by a probe budget: a sparse named graph whose few subjects
175
+ /// are spread over a huge id space returns early with `done = false` instead
176
+ /// of grinding through absent ids, and the caller simply asks again.
177
+ fn dump_batch_inner(&self, graph: Option<&str>, cursor: u32, max_quads: usize) -> DumpBatch {
178
+ let index = match graph {
179
+ None => self.rete.default_index(),
180
+ // An unknown graph IRI is an empty dump, not an error — same as
181
+ // `Rete::dump_each`.
182
+ Some(g) => match self.rete.graph_index(g) {
183
+ Some(i) => i,
184
+ None => return (Vec::new(), cursor, true),
185
+ },
186
+ };
187
+ // Tile leading ranges are known WITHOUT fetching a tile (they live in
188
+ // the section directory), so both the span and the gap jumps below are
189
+ // free on a lazy/remote open.
190
+ let tiles = index.tile_sections()[IndexPermutation::Spo.section_index()];
191
+ let (Some(first), Some(last)) = (
192
+ tiles.first().map(|t| t.leading_range().0),
193
+ tiles.last().map(|t| t.leading_range().1),
194
+ ) else {
195
+ return (Vec::new(), cursor, true); // empty graph
196
+ };
197
+
198
+ let mut sid = cursor.max(first);
199
+ let mut probes = max_quads.saturating_mul(4).max(1 << 16);
200
+ let mut ids: Vec<(u32, u32, u32)> = Vec::new();
201
+ let mut exhausted = false;
202
+ while sid <= last && probes > 0 {
203
+ probes -= 1;
204
+ let hits = index.match_pattern((Some(sid), None, None));
205
+ if hits.is_empty() {
206
+ // No triples at this id. If it also falls in a hole BETWEEN
207
+ // tiles, jump straight to the next tile's first id instead of
208
+ // stepping through the gap one absent id at a time.
209
+ match next_tile_start(tiles, sid) {
210
+ None => {
211
+ exhausted = true;
212
+ break;
213
+ }
214
+ Some(next) if next > sid => {
215
+ sid = next;
216
+ continue;
217
+ }
218
+ Some(_) => {}
219
+ }
220
+ } else {
221
+ ids.extend(hits);
222
+ }
223
+ if sid == u32::MAX {
224
+ exhausted = true;
225
+ break;
226
+ }
227
+ sid += 1;
228
+ // Cut on a subject boundary: `max_quads` is the floor of a batch,
229
+ // never a hard truncation, so no subject is ever split across two
230
+ // batches (which would force a re-scan to resume).
231
+ if ids.len() >= max_quads {
232
+ break;
233
+ }
234
+ }
235
+ let done = exhausted || sid > last;
236
+
237
+ // One coalesced dictionary fault for the whole batch, then resolve.
238
+ // Deliberately NOT `Dictionary::prefetch_all` (what `dump_each` uses):
239
+ // on a lazy open that would pull the *entire* dictionary — gigabytes on
240
+ // a big file — before the first quad, so `islice(g.iter_quads(), 5)`
241
+ // would cost as much as a full dump. Per-batch prefetch keeps a partial
242
+ // walk proportional to what it actually read.
243
+ let dict = self.rete.dictionary();
244
+ if !ids.is_empty() {
245
+ let mut nodes = Vec::with_capacity(ids.len() * 2);
246
+ let mut preds = Vec::with_capacity(ids.len());
247
+ for &(s, p, o) in &ids {
248
+ nodes.push(dict.subject_node(s));
249
+ nodes.push(dict.object_node(o));
250
+ preds.push(p);
251
+ }
252
+ dict.prefetch_terms(&nodes, &preds);
253
+ }
254
+ let triples = ids
255
+ .into_iter()
256
+ .filter_map(|(s, p, o)| {
257
+ Some((
258
+ dict.subject_term(s)?,
259
+ dict.predicate_term(p)?,
260
+ dict.object_term(o)?,
261
+ ))
262
+ })
263
+ .collect();
264
+ (triples, sid, done)
265
+ }
266
+ }
267
+
268
+ /// The first subject id at or after `sid` that any SPO tile can hold, or `None`
269
+ /// when `sid` is past the last tile. Tiles are ascending by leading range, so
270
+ /// this is a binary search; it returns `sid` itself when a tile already covers
271
+ /// it (an ordinary "this subject has no triples" step).
272
+ fn next_tile_start(tiles: &[rete_core::index::Tile], sid: u32) -> Option<u32> {
273
+ let i = tiles.partition_point(|t| t.leading_range().1 < sid);
274
+ tiles.get(i).map(|t| t.leading_range().0.max(sid))
139
275
  }
140
276
 
141
277
  #[pymethods]
@@ -155,13 +291,10 @@ impl Graph {
155
291
  self.fresh_verdict();
156
292
  let out = py
157
293
  .allow_threads(|| -> Result<String, String> {
158
- let shapes =
159
- ShaclShapes::parse_turtle(shapes_turtle).map_err(|e| e.to_string())?;
294
+ let shapes = ShaclShapes::parse_turtle(shapes_turtle).map_err(|e| e.to_string())?;
160
295
  let report = match graph {
161
296
  None => validate_shacl(&ReteGraph::new(&self.rete), &shapes),
162
- Some(g) => {
163
- validate_shacl(&DataGraph::from_rete(&self.rete, Some(g)), &shapes)
164
- }
297
+ Some(g) => validate_shacl(&DataGraph::from_rete(&self.rete, Some(g)), &shapes),
165
298
  };
166
299
  Ok(match format {
167
300
  "ttl" => report.to_turtle(),
@@ -196,6 +329,31 @@ impl Graph {
196
329
  ))
197
330
  }
198
331
 
332
+ /// One bounded slice of a lazy triple dump — the primitive the pure-Python
333
+ /// `Graph.iter_quads()` / `Graph.to_nquads()` drive in a loop.
334
+ ///
335
+ /// `graph` is `None` for the default graph or a named-graph token
336
+ /// (`<iri>`); `cursor` is 0 on the first call and the value returned by the
337
+ /// previous one afterwards. Returns `(triples, next_cursor, done)` where
338
+ /// `triples` holds at most `max_quads` (+ one subject's fan-out) canonical
339
+ /// N-Triples term tokens. See `Graph::dump_batch_inner` for why the cursor
340
+ /// is a subject id.
341
+ #[pyo3(signature = (graph=None, cursor=0, max_quads=DUMP_BATCH))]
342
+ fn dump_batch(
343
+ &self,
344
+ py: Python<'_>,
345
+ graph: Option<&str>,
346
+ cursor: u32,
347
+ max_quads: usize,
348
+ ) -> PyResult<DumpBatch> {
349
+ self.fresh_verdict();
350
+ let out = py.allow_threads(|| self.dump_batch_inner(graph, cursor, max_quads.max(1)));
351
+ // A batch computed over a half-fetched index/dictionary would silently
352
+ // drop quads from the dump; refuse it like every other entry point.
353
+ self.incomplete_guard()?;
354
+ Ok(out)
355
+ }
356
+
199
357
  /// Header summary as JSON:
200
358
  /// `{"quads": N, "terms": N, "pyramidLevels": N, "namedGraphs": N}`.
201
359
  fn info(&self) -> String {
@@ -484,6 +642,9 @@ fn _rete(m: &Bound<'_, PyModule>) -> PyResult<()> {
484
642
  m.add_function(wrap_pyfunction!(open_reader, m)?)?;
485
643
  m.add_function(wrap_pyfunction!(build, m)?)?;
486
644
  m.add_function(wrap_pyfunction!(build_dataset, m)?)?;
645
+ // Single source of truth for the dump batch size, so the pure-Python
646
+ // wrapper's default cannot drift from the binding's.
647
+ m.add("DUMP_BATCH", DUMP_BATCH)?;
487
648
  m.add("__version__", env!("CARGO_PKG_VERSION"))?;
488
649
  // The binding's version tracks the binding; this reports the engine actually
489
650
  // compiled into the wheel, which is what "does my install support X?" means.
@@ -86,3 +86,59 @@ def rete_bytes(nt_text) -> bytes:
86
86
  import rete_graph as rete
87
87
 
88
88
  return rete.build(nt_text)
89
+
90
+
91
+ NQ = """\
92
+ <http://example.org/alice> <http://www.w3.org/2000/01/rdf-schema#label> "Alice" .
93
+ <http://example.org/bob> <http://www.w3.org/2000/01/rdf-schema#label> "Bob" .
94
+ <http://example.org/alice> <http://example.org/knows> <http://example.org/bob> <http://example.org/g1> .
95
+ <http://example.org/alice> <http://example.org/since> "2019"^^<http://www.w3.org/2001/XMLSchema#gYear> <http://example.org/g1> .
96
+ <http://example.org/bob> <http://example.org/knows> <http://example.org/alice> <http://example.org/g2> .
97
+ """
98
+
99
+
100
+ @pytest.fixture(scope="session")
101
+ def nq_text() -> str:
102
+ """Two default-graph triples plus two named graphs (2 + 1 quads)."""
103
+ return NQ
104
+
105
+
106
+ @pytest.fixture(scope="session")
107
+ def nq_bytes(nq_text) -> bytes:
108
+ import rete_graph as rete
109
+
110
+ return rete.build(nq_text, "nq")
111
+
112
+
113
+ #: Triples per subject in the generated fixtures below — enough fan-out that
114
+ #: batch boundaries land inside a subject's group as well as between subjects.
115
+ FANOUT = 5
116
+
117
+
118
+ def _generated_graph(subjects: int) -> bytes:
119
+ """A ``subjects * FANOUT``-triple graph with long-ish literals, so that
120
+ holding every quad is visibly different from streaming them."""
121
+ import rete_graph as rete
122
+
123
+ filler = "x" * 60
124
+ lines = [
125
+ f"<http://example.org/s{s}> <http://example.org/p{i}> "
126
+ f'"value {s}-{i} {filler}" .'
127
+ for s in range(subjects)
128
+ for i in range(FANOUT)
129
+ ]
130
+ return rete.build("\n".join(lines) + "\n")
131
+
132
+
133
+ @pytest.fixture(scope="session")
134
+ def big_rete_bytes() -> bytes:
135
+ """40 000 quads (~0.6 MB): too many to be interesting one at a time, few
136
+ enough to walk several times in a test."""
137
+ return _generated_graph(8_000)
138
+
139
+
140
+ @pytest.fixture(scope="session")
141
+ def multiblock_rete_bytes() -> bytes:
142
+ """200 000 quads (~3.4 MB): large enough to span many read-cache blocks,
143
+ which is what makes "a ranged dump is not a download" measurable."""
144
+ return _generated_graph(40_000)