rete-graph 0.2.1__tar.gz → 0.2.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 (69) hide show
  1. {rete_graph-0.2.1 → rete_graph-0.2.2}/Cargo.toml +1 -1
  2. {rete_graph-0.2.1 → rete_graph-0.2.2}/PKG-INFO +1 -1
  3. rete_graph-0.2.2/clients/python/examples/jupyterlite-build-tutorial.ipynb +315 -0
  4. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/examples/jupyterlite-demo.ipynb +1 -1
  5. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/src/lib.rs +34 -1
  6. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/tests/test_rete_graph.py +37 -0
  7. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/bgp.rs +152 -2
  8. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/block_cache.rs +4 -0
  9. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/dict.rs +9 -0
  10. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/dictionary.rs +56 -0
  11. rete_graph-0.2.2/crates/rete-core/src/extbuild.rs +1680 -0
  12. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/file.rs +18 -2
  13. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/index.rs +47 -1
  14. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/lib.rs +2 -0
  15. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/reader.rs +17 -0
  16. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql/eval.rs +275 -17
  17. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/text_index.rs +28 -0
  18. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/sparql_integration.rs +140 -0
  19. {rete_graph-0.2.1 → rete_graph-0.2.2}/pyproject.toml +1 -1
  20. {rete_graph-0.2.1 → rete_graph-0.2.2}/python/rete_graph/__init__.py +16 -0
  21. {rete_graph-0.2.1 → rete_graph-0.2.2}/README.md +0 -0
  22. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/.gitignore +0 -0
  23. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/Cargo.lock +0 -0
  24. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/Cargo.toml +0 -0
  25. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/README.md +0 -0
  26. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/examples/tutorial.ipynb +0 -0
  27. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/src/readers.rs +0 -0
  28. {rete_graph-0.2.1 → rete_graph-0.2.2}/clients/python/tests/conftest.py +0 -0
  29. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/Cargo.toml +0 -0
  30. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/LICENSE +0 -0
  31. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/README.md +0 -0
  32. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/examples/coherence_bench.rs +0 -0
  33. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/examples/parallel_bench.rs +0 -0
  34. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/examples/qbench.rs +0 -0
  35. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/geo.rs +0 -0
  36. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/header.rs +0 -0
  37. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/ingest.rs +0 -0
  38. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/meta.rs +0 -0
  39. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/parallel.rs +0 -0
  40. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/pyramid.rs +0 -0
  41. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/reach.rs +0 -0
  42. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/reason.rs +0 -0
  43. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/results.rs +0 -0
  44. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/row.rs +0 -0
  45. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/schema_pyramid.rs +0 -0
  46. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/service.rs +0 -0
  47. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/shacl.rs +0 -0
  48. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql/aggregate.rs +0 -0
  49. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql/expr.rs +0 -0
  50. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql/lower.rs +0 -0
  51. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql/path.rs +0 -0
  52. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql/ql.rs +0 -0
  53. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/sparql.rs +0 -0
  54. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/terms.rs +0 -0
  55. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/tiling.rs +0 -0
  56. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/triples.rs +0 -0
  57. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/src/varint.rs +0 -0
  58. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/community_eval.rs +0 -0
  59. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/compatibility_v1.rs +0 -0
  60. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/fixtures/v1/minimal.rete +0 -0
  61. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/fixtures/v1/source.nq +0 -0
  62. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/properties.rs +0 -0
  63. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/public_api.rs +0 -0
  64. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/ranged.rs +0 -0
  65. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/robustness.rs +0 -0
  66. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/roundtrip.rs +0 -0
  67. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/service_federation.rs +0 -0
  68. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/shacl_core.rs +0 -0
  69. {rete_graph-0.2.1 → rete_graph-0.2.2}/crates/rete-core/tests/sparql_nondeterministic.rs +0 -0
@@ -14,7 +14,7 @@ members = [
14
14
  # release gates (--locked checks, feature matrix, coverage floors) stay
15
15
  # untouched by binding-only changes. Future clients (R, Go) follow the same
16
16
  # pattern — clients/ consumes crates/, never the other way around.
17
- exclude = ["fuzz", "clients/python"]
17
+ exclude = ["fuzz", "clients/python", "clients/r/src/rust"]
18
18
 
19
19
  [workspace.package]
20
20
  version = "1.0.0-rc.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rete-graph
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -0,0 +1,315 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "intro",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Anatomy of a `.rete` — build one from an rdflib graph\n",
9
+ "\n",
10
+ "This notebook builds a **knowledge graph with a small ontology** in\n",
11
+ "[rdflib](https://rdflib.readthedocs.io), turns it into a **`.rete` file**,\n",
12
+ "and then dissects every part of that file: the **dictionary**, the **triple\n",
13
+ "indexes**, the **pyramid**, the **Dataset Card**, and the **embedded example\n",
14
+ "queries**. Everything runs in your browser tab (Pyodide + JupyterLite — no\n",
15
+ "server anywhere).\n",
16
+ "\n",
17
+ "Project: <https://github.com/caviri/rete> · docs:\n",
18
+ "[Python API](https://caviri.github.io/rete/python.html) ·\n",
19
+ "[format spec](https://caviri.github.io/rete/SPEC.html)"
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "code",
24
+ "execution_count": null,
25
+ "id": "install",
26
+ "metadata": {},
27
+ "outputs": [],
28
+ "source": [
29
+ "%pip install rete-graph rdflib pandas\n",
30
+ "\n",
31
+ "import pandas as pd\n",
32
+ "import rdflib\n",
33
+ "import rete_graph as rete\n",
34
+ "print(f\"rete-graph {rete.__version__} · rdflib {rdflib.__version__}\")"
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "markdown",
39
+ "id": "kg-md",
40
+ "metadata": {},
41
+ "source": [
42
+ "## 1. A small KG + ontology, in rdflib\n",
43
+ "\n",
44
+ "Eight bodies of the solar system, and — this is the part that makes the rest\n",
45
+ "of the notebook interesting — a tiny **ontology** on top: `Planet` and\n",
46
+ "`Moon` are both `rdfs:subClassOf` **`CelestialBody`**, and the `orbits`\n",
47
+ "property declares its domain and range. No instance is ever typed as a\n",
48
+ "`CelestialBody` directly; later, the reasoner will *infer* that they all are."
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "code",
53
+ "execution_count": null,
54
+ "id": "kg",
55
+ "metadata": {},
56
+ "outputs": [],
57
+ "source": [
58
+ "from rdflib import RDF, RDFS, XSD, Literal, Namespace\n",
59
+ "\n",
60
+ "SOL = Namespace(\"https://example.org/solar/\")\n",
61
+ "kg = rdflib.Graph()\n",
62
+ "\n",
63
+ "# --- the ontology (TBox) ---\n",
64
+ "for cls in (\"CelestialBody\", \"Planet\", \"Moon\"):\n",
65
+ " kg.add((SOL[cls], RDF.type, RDFS.Class))\n",
66
+ "kg.add((SOL.Planet, RDFS.subClassOf, SOL.CelestialBody))\n",
67
+ "kg.add((SOL.Moon, RDFS.subClassOf, SOL.CelestialBody))\n",
68
+ "kg.add((SOL.orbits, RDFS.domain, SOL.CelestialBody))\n",
69
+ "kg.add((SOL.orbits, RDFS.range, SOL.CelestialBody))\n",
70
+ "\n",
71
+ "# --- the data (ABox) ---\n",
72
+ "bodies = {\n",
73
+ " \"Sun\": (None, None, 695_700),\n",
74
+ " \"Mercury\": (\"Planet\", \"Sun\", 2_440),\n",
75
+ " \"Venus\": (\"Planet\", \"Sun\", 6_052),\n",
76
+ " \"Earth\": (\"Planet\", \"Sun\", 6_371),\n",
77
+ " \"Moon\": (\"Moon\", \"Earth\", 1_737),\n",
78
+ " \"Mars\": (\"Planet\", \"Sun\", 3_390),\n",
79
+ " \"Phobos\": (\"Moon\", \"Mars\", 11),\n",
80
+ " \"Deimos\": (\"Moon\", \"Mars\", 6),\n",
81
+ "}\n",
82
+ "for name, (cls, parent, radius) in bodies.items():\n",
83
+ " node = SOL[name]\n",
84
+ " kg.add((node, RDFS.label, Literal(name, lang=\"en\")))\n",
85
+ " kg.add((node, SOL.radius_km, Literal(radius, datatype=XSD.integer)))\n",
86
+ " if cls:\n",
87
+ " kg.add((node, RDF.type, SOL[cls]))\n",
88
+ " if parent:\n",
89
+ " kg.add((node, SOL.orbits, SOL[parent]))\n",
90
+ "\n",
91
+ "print(f\"{len(kg)} triples in the rdflib graph — a taste, as Turtle:\\n\")\n",
92
+ "print(\"\\n\".join(kg.serialize(format=\"turtle\").splitlines()[8:16]))"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "markdown",
97
+ "id": "build-md",
98
+ "metadata": {},
99
+ "source": [
100
+ "## 2. From rdflib to `.rete` — what the Builder assembles\n",
101
+ "\n",
102
+ "`rete.Builder()` accepts the rdflib graph directly (anything with a\n",
103
+ "`.serialize()` method works). Configuration is **lazy** — each call just\n",
104
+ "records intent — and `run()` assembles the actual file, which is built from\n",
105
+ "four kinds of sections:\n",
106
+ "\n",
107
+ "| Part | What it is | Why it exists |\n",
108
+ "|---|---|---|\n",
109
+ "| **Dictionary** | every distinct term (IRI, literal, bnode) stored **once**, sorted, in role-split sections | queries compare small integer ids, not strings; sorted order → prefix lookups & compression |\n",
110
+ "| **Triple indexes** | the id-triples in **three permutations** (SPO / POS / OSP) with zone maps | any triple pattern — `(s ? ?)`, `(? p ?)`, `(? ? o)` — becomes a *range scan*, which is also what makes lazy HTTP-range querying possible |\n",
111
+ "| **Pyramid** | a leveled *summary* of the graph — communities of nodes, rolled up level by level, plus the label index | \"zoom out\" views, the schema profile, and label autocomplete, all readable without touching the big indexes |\n",
112
+ "| **Dataset Card** | a JSON self-description in the metadata section | the file documents itself: title, license, counts, and runnable starter queries |\n",
113
+ "\n",
114
+ "For the pyramid we choose `algo=\"types\"`: **one community per `rdf:type`\n",
115
+ "class** — for an ontology-shaped graph like ours, the summary *is* the class\n",
116
+ "structure. (The default, `louvain`, clusters by connectivity instead.)"
117
+ ]
118
+ },
119
+ {
120
+ "cell_type": "code",
121
+ "execution_count": null,
122
+ "id": "build",
123
+ "metadata": {},
124
+ "outputs": [],
125
+ "source": "builder = (\n rete.Builder()\n .add(kg) # the rdflib graph, directly\n .card(\n title=\"Solar system micro-KG\",\n description=\"Eight bodies + a tiny ontology (Planet/Moon ⊑ CelestialBody), \"\n \"authored in rdflib inside a browser tab.\",\n license=\"CC0-1.0\",\n created=\"2026-07-17\",\n )\n .example(\n \"SELECT ?body ?parent WHERE { ?body <https://example.org/solar/orbits> ?parent }\",\n title=\"Who orbits whom?\",\n question=\"Which body orbits which other body?\",\n )\n .example(\n \"SELECT DISTINCT ?b WHERE { ?b a <https://example.org/solar/CelestialBody> }\",\n title=\"All celestial bodies (needs reasoning!)\",\n question=\"Which things are celestial bodies — including inferred ones?\",\n )\n .pyramid(algo=\"types\") # communities = classes\n .text_index() # opt-in full-text index\n)\n\ndata = builder.run()\nprint(f\"{len(data):,} bytes\")\nbuilder.stats"
126
+ },
127
+ {
128
+ "cell_type": "markdown",
129
+ "id": "dict-md",
130
+ "metadata": {},
131
+ "source": [
132
+ "## 3. The dictionary — terms are stored once\n",
133
+ "\n",
134
+ "Look at `stats` above: the file holds more **statements** than **terms**.\n",
135
+ "Every IRI like `sol:Sun` appears in many triples but is written once; each\n",
136
+ "triple is just three small integers pointing into the dictionary. That's the\n",
137
+ "same trick as HDT and column stores — and because the dictionary is sorted\n",
138
+ "and chunked, a remote client can fetch *only the chunks a query touches* and\n",
139
+ "run **prefix searches** against the label index without a full scan:"
140
+ ]
141
+ },
142
+ {
143
+ "cell_type": "code",
144
+ "execution_count": null,
145
+ "id": "dict",
146
+ "metadata": {},
147
+ "outputs": [],
148
+ "source": [
149
+ "g = builder.graph()\n",
150
+ "print(f\"statements: {g.quads} · distinct terms: {g.terms}\")\n",
151
+ "g.prefix_search(\"M\") # label index: everything labelled M… (Moon, Mars, Mercury)"
152
+ ]
153
+ },
154
+ {
155
+ "cell_type": "markdown",
156
+ "id": "pyramid-md",
157
+ "metadata": {},
158
+ "source": [
159
+ "## 4. The pyramid — the graph, summarized by class\n",
160
+ "\n",
161
+ "`pyramidLevels` in the build stats came from the `types` algorithm: level 0\n",
162
+ "groups every node under its `rdf:type` class, and the **schema profile** is\n",
163
+ "exactly that quotient graph — which classes exist, and how they relate\n",
164
+ "through predicates. A client reads this in a couple of small range requests\n",
165
+ "*before* deciding what to query — it's the \"map\" you see first in the\n",
166
+ "[playground](https://caviri.github.io/rete/playground.html):"
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "code",
171
+ "execution_count": null,
172
+ "id": "pyramid",
173
+ "metadata": {},
174
+ "outputs": [],
175
+ "source": [
176
+ "s = g.schema()\n",
177
+ "display(pd.DataFrame(s[\"classes\"], columns=[\"class\", \"instances\"]))\n",
178
+ "pd.DataFrame(s[\"relations\"], columns=[\"subject class\", \"predicate\", \"object class\", \"count\"])"
179
+ ]
180
+ },
181
+ {
182
+ "cell_type": "markdown",
183
+ "id": "card-md",
184
+ "metadata": {},
185
+ "source": [
186
+ "## 5. The Dataset Card — the file documents itself\n",
187
+ "\n",
188
+ "The card travels **inside** the file's metadata section. We wrote the curated\n",
189
+ "fields (`title`, `description`, `license`, `created`); the counts and\n",
190
+ "`format_version` were stamped automatically at build time. Every client reads\n",
191
+ "the same card — this notebook, the CLI (`rete card solar.rete`), the\n",
192
+ "playground's catalog view:"
193
+ ]
194
+ },
195
+ {
196
+ "cell_type": "code",
197
+ "execution_count": null,
198
+ "id": "card",
199
+ "metadata": {},
200
+ "outputs": [],
201
+ "source": [
202
+ "card = g.card()\n",
203
+ "{k: card[k] for k in (\"title\", \"description\", \"license\", \"created\",\n",
204
+ " \"triple_count\", \"term_count\", \"format_version\")}"
205
+ ]
206
+ },
207
+ {
208
+ "cell_type": "markdown",
209
+ "id": "examples-md",
210
+ "metadata": {},
211
+ "source": [
212
+ "## 6. Embedded example queries — a dataset that ships its own questions\n",
213
+ "\n",
214
+ "The two `.example()` calls landed in the card's starter-query library. Anyone\n",
215
+ "who opens this file — today or in ten years, in any client — can list them\n",
216
+ "and run them as-is:"
217
+ ]
218
+ },
219
+ {
220
+ "cell_type": "code",
221
+ "execution_count": null,
222
+ "id": "examples",
223
+ "metadata": {},
224
+ "outputs": [],
225
+ "source": [
226
+ "examples = g.examples()\n",
227
+ "display(pd.DataFrame(examples)[[\"title\", \"question\"]])\n",
228
+ "g.query_df(examples[0][\"sparql\"]) # \"Who orbits whom?\", straight from the file"
229
+ ]
230
+ },
231
+ {
232
+ "cell_type": "markdown",
233
+ "id": "reason-md",
234
+ "metadata": {},
235
+ "source": [
236
+ "## 7. The ontology at work — reasoning by query rewriting\n",
237
+ "\n",
238
+ "Remember: nothing is *asserted* to be a `CelestialBody`. A plain query finds\n",
239
+ "nothing — but with `reason=True` the engine rewrites the query through the\n",
240
+ "ontology (`Planet ⊑ CelestialBody`, `Moon ⊑ CelestialBody`, plus the\n",
241
+ "`orbits` domain/range), computing OWL 2 QL entailment **without\n",
242
+ "materializing a single new triple**. That's why it works over lazy remote\n",
243
+ "files too:"
244
+ ]
245
+ },
246
+ {
247
+ "cell_type": "code",
248
+ "execution_count": null,
249
+ "id": "reason",
250
+ "metadata": {},
251
+ "outputs": [],
252
+ "source": [
253
+ "q = examples[1][\"sparql\"] # \"All celestial bodies\" from the card\n",
254
+ "print(\"asserted only:\", len(g.query(q)), \"rows\")\n",
255
+ "print(\"with OWL 2 QL reasoning:\")\n",
256
+ "g.query_df(q, reason=True) # planets, moons — and the Sun, via orbits' range"
257
+ ]
258
+ },
259
+ {
260
+ "cell_type": "markdown",
261
+ "id": "export-md",
262
+ "metadata": {},
263
+ "source": [
264
+ "## 8. Export — one immutable file, queryable anywhere\n",
265
+ "\n",
266
+ "`export()` writes the finished file (here into the notebook's in-browser\n",
267
+ "filesystem — check the file browser on the left; you can download it). Host\n",
268
+ "it on anything that serves HTTP `Range` and it is queryable in place from\n",
269
+ "Python, JavaScript, the CLI, and the playground — carrying its card, its\n",
270
+ "examples, and its ontology with it. For datasets beyond a few million\n",
271
+ "triples, graduate to the streaming\n",
272
+ "[`rete build` CLI](https://caviri.github.io/rete/cli.html)."
273
+ ]
274
+ },
275
+ {
276
+ "cell_type": "code",
277
+ "execution_count": null,
278
+ "id": "export",
279
+ "metadata": {},
280
+ "outputs": [],
281
+ "source": "path = builder.export(\"solar.rete\")\nreopened = rete.open(path)\nprint(path, \"·\", reopened.quads, \"triples · content hash\", reopened.content_hash())"
282
+ },
283
+ {
284
+ "cell_type": "markdown",
285
+ "id": "outro",
286
+ "metadata": {},
287
+ "source": [
288
+ "---\n",
289
+ "\n",
290
+ "**Where next:** the [guided tour notebook](rete-graph.ipynb) (remote graphs,\n",
291
+ "lazy range reads) · [Python build tutorial](https://caviri.github.io/rete/python-build-tutorial.html) ·\n",
292
+ "[Dataset Cards](https://caviri.github.io/rete/dataset-cards.html) ·\n",
293
+ "[semantic zoom / pyramid](https://caviri.github.io/rete/semantic-zoom.html) ·\n",
294
+ "[reasoning](https://caviri.github.io/rete/reasoning.html)\n",
295
+ "\n",
296
+ "Source & issues: **<https://github.com/caviri/rete>**\n",
297
+ "\n",
298
+ "© 2026 Carlos Vivar Ríos — released under the\n",
299
+ "[Apache License 2.0](https://github.com/caviri/rete/blob/main/LICENSE)."
300
+ ]
301
+ }
302
+ ],
303
+ "metadata": {
304
+ "kernelspec": {
305
+ "display_name": "Python (Pyodide)",
306
+ "language": "python",
307
+ "name": "python"
308
+ },
309
+ "language_info": {
310
+ "name": "python"
311
+ }
312
+ },
313
+ "nbformat": 4,
314
+ "nbformat_minor": 5
315
+ }
@@ -30,7 +30,7 @@
30
30
  "id": "install",
31
31
  "metadata": {},
32
32
  "outputs": [],
33
- "source": "# Pyodide is mid-transition on binary wheel tags (PEP 783): this deployment\n# pins Pyodide 0.29, whose installer predates the new PyPI tag spelling — so\n# we install the SAME published wheel from the project's bucket. On newer\n# runtimes, a plain `%pip install rete-graph` resolves from PyPI.\n%pip install pandas\n%pip install https://data.graphplaza.com/wheels/rete_graph-0.2.0-cp39-abi3-pyodide_2025_0_wasm32.whl\n\nimport sys\nimport rete_graph as rete\nprint(f\"rete-graph {rete.__version__} running on platform: {sys.platform!r}\")"
33
+ "source": "%pip install rete-graph pandas\n\nimport sys\nimport rete_graph as rete\nprint(f\"rete-graph {rete.__version__} running on platform: {sys.platform!r}\")"
34
34
  },
35
35
  {
36
36
  "cell_type": "markdown",
@@ -18,7 +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
- BlockCacheReader, CountingReader, RangeReader, Rete, DEFAULT_BLOCK,
21
+ validate_shacl, BlockCacheReader, CountingReader, DataGraph, RangeReader, Rete, ReteGraph,
22
+ ShaclShapes, DEFAULT_BLOCK,
22
23
  };
23
24
  #[cfg(not(target_os = "emscripten"))]
24
25
  use rete_core::{parse_sparql_json_results, Binding, ServiceClient};
@@ -130,6 +131,38 @@ impl Graph {
130
131
 
131
132
  #[pymethods]
132
133
  impl Graph {
134
+ /// Validate the graph against SHACL Core shapes written in Turtle.
135
+ /// Lazy-aware: over the default graph only the shapes' targets are
136
+ /// fetched (the index is consulted in place); a named `graph` view
137
+ /// materializes that graph first. `format` is "json" (default) or "ttl".
138
+ #[pyo3(signature = (shapes_turtle, *, graph=None, format="json"))]
139
+ fn shacl(
140
+ &self,
141
+ py: Python<'_>,
142
+ shapes_turtle: &str,
143
+ graph: Option<&str>,
144
+ format: &str,
145
+ ) -> PyResult<String> {
146
+ let out = py
147
+ .allow_threads(|| -> Result<String, String> {
148
+ let shapes =
149
+ ShaclShapes::parse_turtle(shapes_turtle).map_err(|e| e.to_string())?;
150
+ let report = match graph {
151
+ None => validate_shacl(&ReteGraph::new(&self.rete), &shapes),
152
+ Some(g) => {
153
+ validate_shacl(&DataGraph::from_rete(&self.rete, Some(g)), &shapes)
154
+ }
155
+ };
156
+ Ok(match format {
157
+ "ttl" => report.to_turtle(),
158
+ _ => report.to_json(),
159
+ })
160
+ })
161
+ .map_err(PyRuntimeError::new_err)?;
162
+ self.incomplete_guard()?;
163
+ Ok(out)
164
+ }
165
+
133
166
  /// Run a SPARQL query (SELECT / ASK / CONSTRUCT / DESCRIBE). Returns the
134
167
  /// JSON envelope `{"kind": "select", "vars": [...], "rows": [...]}` etc.;
135
168
  /// the Python wrapper parses it into values. `reason=True` turns on OWL 2
@@ -97,6 +97,43 @@ def test_open_custom_reader(rete_bytes):
97
97
  assert r.calls >= 1
98
98
 
99
99
 
100
+ SHAPES_PREFIX = """\
101
+ @prefix sh: <http://www.w3.org/ns/shacl#> .
102
+ @prefix ex: <http://example.org/> .
103
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
104
+ """
105
+
106
+
107
+ def test_shacl_validation(rete_bytes, serve_bytes):
108
+ g = rete.open(rete_bytes)
109
+
110
+ labeled = SHAPES_PREFIX + """
111
+ ex:PersonShape a sh:NodeShape ;
112
+ sh:targetClass ex:Person ;
113
+ sh:property [ sh:path rdfs:label ; sh:minCount 1 ] .
114
+ """
115
+ report = g.shacl(labeled)
116
+ assert report["conforms"] is True
117
+ assert report["results"] == []
118
+
119
+ emailed = SHAPES_PREFIX + """
120
+ ex:PersonShape a sh:NodeShape ;
121
+ sh:targetClass ex:Person ;
122
+ sh:property [ sh:path ex:email ; sh:minCount 1 ] .
123
+ """
124
+ report = g.shacl(emailed)
125
+ assert report["conforms"] is False
126
+ assert len(report["results"]) == 2 # alice and bob both lack ex:email
127
+
128
+ ttl = g.shacl(emailed, format="ttl")
129
+ assert "ValidationReport" in ttl
130
+
131
+ # Lazy path: same verdicts over HTTP range reads.
132
+ remote = rete.open(serve_bytes(rete_bytes))
133
+ assert remote.shacl(labeled)["conforms"] is True
134
+ assert remote.shacl(emailed)["conforms"] is False
135
+
136
+
100
137
  def test_schema_and_search(rete_bytes):
101
138
  g = rete.open(rete_bytes)
102
139
  classes = dict(g.schema()["classes"])
@@ -17,7 +17,7 @@
17
17
  use std::collections::{BTreeMap, HashMap};
18
18
 
19
19
  use crate::file::Rete;
20
- use crate::index::{GraphIndex, Pattern};
20
+ use crate::index::{GraphIndex, Pattern, Tile};
21
21
  use crate::row::{Ctx, Row, Slots, Val};
22
22
 
23
23
  /// A term in a pattern: a named variable or a constant term token.
@@ -189,7 +189,7 @@ pub(crate) fn eval_bgp_rows(ctx: &Ctx, index: &GraphIndex, patterns: &[TriplePat
189
189
  && shares_bound
190
190
  && !rows.is_empty()
191
191
  && if index.is_remote() {
192
- rows.len() <= REMOTE_PROBE_MAX
192
+ rows.len() <= remote_probe_max(index)
193
193
  && (rows.len() <= REMOTE_PROBE_MIN || !pattern_is_selective(&t))
194
194
  } else {
195
195
  rows.len() <= BGP_PROBE_THRESHOLD
@@ -275,6 +275,22 @@ fn try_merge_join(
275
275
  if ca != cb {
276
276
  return None; // cross-role: the two role id-spaces aren't co-sorted
277
277
  }
278
+ // A merge MATERIALIZES both sides in full (`collect` below builds a Vec per
279
+ // side). When one side is a pinpoint (a bound leading key routing to a few
280
+ // KB of tiles) and the other is fat (a broad predicate spanning megabytes),
281
+ // the linear merge costs building — and, on a remote index, FETCHING — the
282
+ // fat extent end-to-end: gigabytes over HTTP, and the 32-bit wasm OOM
283
+ // behind `?x wdt:P279 <C> . ?x rdfs:label ?l` on a 185M-triple remote
284
+ // graph. The hybrid loop handles that shape strictly better (scan the
285
+ // pinpoint side, probe the fat one per surviving row), so leave the merge
286
+ // seed to comparably-sized sides.
287
+ let (lo, hi) = {
288
+ let (a, b) = (pattern_scan_bytes(index, ta), pattern_scan_bytes(index, tb));
289
+ (a.min(b), a.max(b))
290
+ };
291
+ if hi >= FAT_SCAN_BYTES && hi / 4 >= lo {
292
+ return None;
293
+ }
278
294
  let lower = |t: &(SlotTerm, SlotTerm, SlotTerm)| -> Option<Pattern> {
279
295
  Some((
280
296
  const_subject(&t.0, dict)?,
@@ -366,6 +382,87 @@ const BGP_PROBE_THRESHOLD: usize = 512;
366
382
  const REMOTE_PROBE_MIN: usize = 8;
367
383
  const REMOTE_PROBE_MAX: usize = 1024;
368
384
 
385
+ /// The remote probe budget, widened by the reader's concurrency: a serial
386
+ /// sync-XHR reader (a phone without the COI pool) pays one full round trip per
387
+ /// probe, so its budget stays at [`REMOTE_PROBE_MAX`]; a reader overlapping
388
+ /// 16 range reads (the CLI's thread pool, the asyncified fetch variant, the
389
+ /// COI fetch-worker pool) amortizes ~16 probes per round trip and gets a
390
+ /// proportionally larger budget before the one-pass scan wins again.
391
+ fn remote_probe_max(index: &GraphIndex) -> usize {
392
+ REMOTE_PROBE_MAX * index.read_concurrency().clamp(1, 16)
393
+ }
394
+
395
+ /// Batch-fault the tiles that a [`ProbePlan`]'s FIRST pattern will route to
396
+ /// across a batch of seed rows — one coalesced read per section instead of a
397
+ /// blocking round trip per row. Only the first pattern is prefetchable (later
398
+ /// ones bind from earlier probe results), and only rows whose seed binds the
399
+ /// pattern's subject or object are included: a predicate-only pattern routes
400
+ /// to the predicate's WHOLE span, which for the fat sides this path serves
401
+ /// would prefetch the very extent the probe strategy exists to avoid.
402
+ pub(crate) fn prefetch_plan_probes(ctx: &Ctx, index: &GraphIndex, plan: &ProbePlan, rows: &[Row]) {
403
+ if !index.is_remote() {
404
+ return;
405
+ }
406
+ let Some(t) = plan.pats.first() else {
407
+ return;
408
+ };
409
+ let mut pats: Vec<Pattern> = Vec::new();
410
+ for base in rows {
411
+ if let (Some(s), Some(p), Some(o)) = (
412
+ probe_subject(ctx, &t.0, base),
413
+ probe_predicate(ctx, &t.1, base),
414
+ probe_object(ctx, &t.2, base),
415
+ ) {
416
+ if s.is_some() || o.is_some() {
417
+ pats.push((s, p, o));
418
+ }
419
+ }
420
+ }
421
+ index.prefetch_probe_tiles(&pats);
422
+ }
423
+
424
+ /// Batch-fault the tiles that probing `patterns` from `subject_ids` (each
425
+ /// bound onto the subject variable `sv`) will route to — one coalesced read
426
+ /// per section instead of one round trip per candidate. Purely a cache
427
+ /// warmer: correctness is untouched, and a local index is a no-op. Used by
428
+ /// the FILTER-CONTAINS pushdown before it probes its candidate subjects.
429
+ pub(crate) fn prefetch_subject_probes(
430
+ ctx: &Ctx,
431
+ index: &GraphIndex,
432
+ patterns: &[TriplePattern],
433
+ sv: &str,
434
+ subject_ids: &[u32],
435
+ ) {
436
+ if !index.is_remote() {
437
+ return;
438
+ }
439
+ let Some(lowered) = lower(patterns, ctx) else {
440
+ return;
441
+ };
442
+ let Some(slot) = ctx.slots.slot(sv) else {
443
+ return;
444
+ };
445
+ let dict = ctx.rete.dictionary();
446
+ let mut pats: Vec<Pattern> = Vec::new();
447
+ for &sid in subject_ids {
448
+ let mut base = ctx.slots.empty_row();
449
+ base[slot] = Some(Val::Id(dict.subject_node(sid) as i64));
450
+ for t in &lowered {
451
+ if !matches!(t.0, SlotTerm::Var(i) if i == slot) {
452
+ continue;
453
+ }
454
+ if let (Some(s), Some(p), Some(o)) = (
455
+ probe_subject(ctx, &t.0, &base),
456
+ probe_predicate(ctx, &t.1, &base),
457
+ probe_object(ctx, &t.2, &base),
458
+ ) {
459
+ pats.push((s, p, o));
460
+ }
461
+ }
462
+ }
463
+ index.prefetch_probe_tiles(&pats);
464
+ }
465
+
369
466
  /// A pattern whose own constant terms (a bound subject or object) already route
370
467
  /// it to a narrow index range — a few coalesced range reads to scan in full.
371
468
  fn pattern_is_selective(t: &(SlotTerm, SlotTerm, SlotTerm)) -> bool {
@@ -531,6 +628,59 @@ fn pattern_estimates(
531
628
  )
532
629
  }
533
630
 
631
+ /// The minimum, over a BGP's patterns, of [`pattern_scan_bytes`] — the
632
+ /// cheapest single-pattern scan the hash-join path would at least perform to
633
+ /// materialize this BGP. `None` = a constant term is unknown to the
634
+ /// dictionary (the BGP is unsatisfiable; the caller short-circuits anyway).
635
+ /// Unlike [`pattern_estimates`], this needs no pyramid summary, so it works on
636
+ /// remote-lazy files — exactly where the answer matters most.
637
+ pub(crate) fn bgp_min_scan_bytes(
638
+ ctx: &Ctx,
639
+ index: &GraphIndex,
640
+ patterns: &[TriplePattern],
641
+ ) -> Option<u64> {
642
+ let lowered = lower(patterns, ctx)?;
643
+ lowered.iter().map(|t| pattern_scan_bytes(index, t)).min()
644
+ }
645
+
646
+ /// One pattern's cheapest scan extent in BYTES: for each permutation, sum the
647
+ /// encoded lengths of the tiles a scan of this pattern would route to (all
648
+ /// tiles covering the bound leading key, or the whole section when the leading
649
+ /// key is unbound), and take the minimum. Tile *counts* are useless here — a
650
+ /// broad predicate can sit inside one giant tile — but the directory's byte
651
+ /// lengths expose it. Reads only resident metadata, never tile data.
652
+ fn pattern_scan_bytes(index: &GraphIndex, t: &(SlotTerm, SlotTerm, SlotTerm)) -> u64 {
653
+ let sections = index.tile_sections();
654
+ let comp = |role: usize| match role {
655
+ 0 => &t.0,
656
+ 1 => &t.1,
657
+ _ => &t.2,
658
+ };
659
+ let mut best = u64::MAX;
660
+ for perm in crate::index::ALL_PERMS {
661
+ let tiles = sections[perm.section_index()];
662
+ best = best.min(match comp(perm.roles()[0]) {
663
+ SlotTerm::Node(id) | SlotTerm::Pred(id) => tiles
664
+ .iter()
665
+ .filter(|tile| {
666
+ let (lo, hi) = tile.leading_range();
667
+ lo <= *id && *id <= hi
668
+ })
669
+ .map(Tile::encoded_len)
670
+ .sum(),
671
+ SlotTerm::Var(_) => tiles.iter().map(Tile::encoded_len).sum(),
672
+ });
673
+ }
674
+ best
675
+ }
676
+
677
+ /// Scan extent above which a join side counts as "fat": materializing (or, on
678
+ /// a remote index, fetching) ≥2 MiB of encoded tiles to build a hash/merge
679
+ /// side is past the point where probing it per surviving row wins. Shared by
680
+ /// the merge-seed asymmetry gate here and the left-join force-probe gate in
681
+ /// `sparql::eval`.
682
+ pub(crate) const FAT_SCAN_BYTES: u64 = 2 << 20;
683
+
534
684
  /// Order pattern indices for a left-deep join: cheapest (smallest estimated
535
685
  /// cardinality) first, always preferring a pattern that shares a variable with
536
686
  /// the already-joined set so the join stays connected and intermediate relations
@@ -256,6 +256,10 @@ impl<R: RangeReader> RangeReader for BlockCacheReader<R> {
256
256
  self.len
257
257
  }
258
258
 
259
+ fn concurrency(&self) -> usize {
260
+ self.inner.concurrency()
261
+ }
262
+
259
263
  fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
260
264
  if len == 0 {
261
265
  return Ok(Vec::new());
@@ -494,6 +494,15 @@ impl ChunkedSection {
494
494
  }
495
495
 
496
496
  /// Resolve `id` (1-based) to its term. One chunk fault at most.
497
+ /// Chunk index holding term `id` — [`term`](Self::term)'s routing without
498
+ /// faulting the chunk. `None` for an absent/out-of-range id.
499
+ pub(crate) fn chunk_of_term(&self, id: u32) -> Option<usize> {
500
+ if id == ABSENT || id > self.meta.term_count {
501
+ return None;
502
+ }
503
+ self.chunk_of_run((id - 1) as usize / self.meta.restart_interval as usize)
504
+ }
505
+
497
506
  pub fn term(&self, id: u32) -> Option<String> {
498
507
  if id == ABSENT || id > self.meta.term_count {
499
508
  return None;