aevum-store-oxigraph 0.2.0__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.
- aevum_store_oxigraph-0.2.0/.gitignore +31 -0
- aevum_store_oxigraph-0.2.0/PKG-INFO +36 -0
- aevum_store_oxigraph-0.2.0/README.md +17 -0
- aevum_store_oxigraph-0.2.0/pyproject.toml +54 -0
- aevum_store_oxigraph-0.2.0/src/aevum/store/oxigraph/__init__.py +21 -0
- aevum_store_oxigraph-0.2.0/src/aevum/store/oxigraph/py.typed +0 -0
- aevum_store_oxigraph-0.2.0/src/aevum/store/oxigraph/store.py +252 -0
- aevum_store_oxigraph-0.2.0/src/aevum/store/oxigraph/vocabulary.py +26 -0
- aevum_store_oxigraph-0.2.0/tests/test_engine_integration.py +115 -0
- aevum_store_oxigraph-0.2.0/tests/test_store.py +142 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Build
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
|
|
13
|
+
# Tools
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.hypothesis/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
|
28
|
+
|
|
29
|
+
# Verify scripts (run locally, never commit)
|
|
30
|
+
verify_phase*.py
|
|
31
|
+
scripts/verify_phase*.py
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevum-store-oxigraph
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Aevum — Oxigraph GraphStore backend (small deployments).
|
|
5
|
+
Project-URL: Homepage, https://aevum.build
|
|
6
|
+
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: aevum-core
|
|
17
|
+
Requires-Dist: pyoxigraph<0.6,>=0.5
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# aevum-store-oxigraph
|
|
21
|
+
|
|
22
|
+
Oxigraph-backed graph store for Aevum. Suitable for single-node and embedded deployments. No external database required.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install aevum-store-oxigraph
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from aevum.core import Engine
|
|
30
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
31
|
+
|
|
32
|
+
engine = Engine(graph_store=OxigraphStore(path="./aevum-data"))
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For team deployments requiring shared state, use `aevum-store-postgres` instead.
|
|
36
|
+
See the [main repository README](https://github.com/aevum-labs/aevum) for backend selection guidance.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# aevum-store-oxigraph
|
|
2
|
+
|
|
3
|
+
Oxigraph-backed graph store for Aevum. Suitable for single-node and embedded deployments. No external database required.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install aevum-store-oxigraph
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from aevum.core import Engine
|
|
11
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
12
|
+
|
|
13
|
+
engine = Engine(graph_store=OxigraphStore(path="./aevum-data"))
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For team deployments requiring shared state, use `aevum-store-postgres` instead.
|
|
17
|
+
See the [main repository README](https://github.com/aevum-labs/aevum) for backend selection guidance.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aevum-store-oxigraph"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Aevum — Oxigraph GraphStore backend (small deployments)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"aevum-core",
|
|
19
|
+
"pyoxigraph>=0.5,<0.6",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://aevum.build"
|
|
24
|
+
Repository = "https://github.com/aevum-labs/aevum"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/aevum"]
|
|
32
|
+
|
|
33
|
+
[tool.uv.sources]
|
|
34
|
+
aevum-core = { workspace = true }
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
addopts = "--tb=short"
|
|
40
|
+
pythonpath = ["src", "tests"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
strict = true
|
|
44
|
+
python_version = "3.11"
|
|
45
|
+
mypy_path = "src"
|
|
46
|
+
explicit_package_bases = true
|
|
47
|
+
ignore_missing_imports = true
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 130
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
|
|
54
|
+
ignore = ["ANN401"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aevum.store.oxigraph — Oxigraph GraphStore backend.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
6
|
+
from aevum.core import Engine
|
|
7
|
+
|
|
8
|
+
# In-memory (dev/test):
|
|
9
|
+
store = OxigraphStore()
|
|
10
|
+
|
|
11
|
+
# Disk-backed (production):
|
|
12
|
+
store = OxigraphStore(path="/var/lib/aevum/graph")
|
|
13
|
+
|
|
14
|
+
engine = Engine(graph_store=store)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from aevum.store.oxigraph.store import OxigraphStore
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = ["OxigraphStore"]
|
|
File without changes
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OxigraphStore — GraphStore Protocol backed by pyoxigraph.
|
|
3
|
+
|
|
4
|
+
Satisfies the aevum.core.protocols.graph_store.GraphStore Protocol.
|
|
5
|
+
Uses three Named Graphs (frozen invariants from spec Section 04.3):
|
|
6
|
+
urn:aevum:knowledge — working graph (entities and relationships)
|
|
7
|
+
urn:aevum:provenance — per-entity provenance records as quads
|
|
8
|
+
urn:aevum:consent — consent ledger (managed by aevum-core)
|
|
9
|
+
|
|
10
|
+
RDF-star is NOT used (dropped in pyoxigraph 0.5).
|
|
11
|
+
Provenance is stored as separate quads in urn:aevum:provenance.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import json
|
|
18
|
+
import threading
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pyoxigraph import Literal, NamedNode, Quad, QuerySolutions, Store
|
|
23
|
+
|
|
24
|
+
from aevum.store.oxigraph.vocabulary import (
|
|
25
|
+
PRED_CLASS_LVL,
|
|
26
|
+
PRED_INGEST_AT,
|
|
27
|
+
PRED_SUBJECT_ID,
|
|
28
|
+
PRED_TYPE,
|
|
29
|
+
TYPE_ENTITY,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Three Named Graphs — FROZEN INVARIANTS (spec Section 04.3)
|
|
33
|
+
GRAPH_KNOWLEDGE = NamedNode("urn:aevum:knowledge")
|
|
34
|
+
GRAPH_PROVENANCE = NamedNode("urn:aevum:provenance")
|
|
35
|
+
GRAPH_CONSENT = NamedNode("urn:aevum:consent")
|
|
36
|
+
|
|
37
|
+
# XSD datatypes
|
|
38
|
+
XSD_STRING = NamedNode("http://www.w3.org/2001/XMLSchema#string")
|
|
39
|
+
XSD_INTEGER = NamedNode("http://www.w3.org/2001/XMLSchema#integer")
|
|
40
|
+
XSD_DATETIME = NamedNode("http://www.w3.org/2001/XMLSchema#dateTime")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _entity_node(entity_id: str) -> NamedNode:
|
|
44
|
+
"""Map an entity_id string to an RDF NamedNode IRI."""
|
|
45
|
+
if entity_id.startswith("http") or entity_id.startswith("urn:"):
|
|
46
|
+
return NamedNode(entity_id)
|
|
47
|
+
return NamedNode(f"urn:aevum:entity:{entity_id}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _lit_str(value: str) -> Literal:
|
|
51
|
+
return Literal(value, datatype=XSD_STRING)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _lit_int(value: int) -> Literal:
|
|
55
|
+
return Literal(str(value), datatype=XSD_INTEGER)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _lit_dt(value: str) -> Literal:
|
|
59
|
+
return Literal(value, datatype=XSD_DATETIME)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OxigraphStore:
|
|
63
|
+
"""
|
|
64
|
+
GraphStore implementation backed by pyoxigraph.
|
|
65
|
+
|
|
66
|
+
Thread-safe via internal lock.
|
|
67
|
+
Satisfies aevum.core.protocols.graph_store.GraphStore Protocol.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Create an OxigraphStore.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: Directory for disk-backed persistence.
|
|
76
|
+
None = in-memory (dev/test only, lost on restart).
|
|
77
|
+
"""
|
|
78
|
+
if path is not None:
|
|
79
|
+
self._store = Store(str(path))
|
|
80
|
+
else:
|
|
81
|
+
self._store = Store()
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
self._init_named_graphs()
|
|
84
|
+
|
|
85
|
+
def _init_named_graphs(self) -> None:
|
|
86
|
+
"""Ensure all three Named Graphs exist in the store."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
for graph in (GRAPH_KNOWLEDGE, GRAPH_PROVENANCE, GRAPH_CONSENT):
|
|
89
|
+
self._store.add_graph(graph)
|
|
90
|
+
|
|
91
|
+
def store_entity(
|
|
92
|
+
self,
|
|
93
|
+
entity_id: str,
|
|
94
|
+
data: dict[str, Any],
|
|
95
|
+
classification: int = 0,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Store or update an entity in urn:aevum:knowledge.
|
|
99
|
+
Stores provenance metadata in urn:aevum:provenance.
|
|
100
|
+
|
|
101
|
+
Classification level enforces Barrier 2 at read time.
|
|
102
|
+
"""
|
|
103
|
+
node = _entity_node(entity_id)
|
|
104
|
+
now_iso = datetime.datetime.now(datetime.UTC).isoformat()
|
|
105
|
+
|
|
106
|
+
with self._lock:
|
|
107
|
+
# Remove existing quads for this entity before re-inserting
|
|
108
|
+
# (update semantics — last write wins within a subject)
|
|
109
|
+
existing = list(self._store.quads_for_pattern(
|
|
110
|
+
node, None, None, GRAPH_KNOWLEDGE
|
|
111
|
+
))
|
|
112
|
+
for quad in existing:
|
|
113
|
+
self._store.remove(quad)
|
|
114
|
+
|
|
115
|
+
# Core type quad
|
|
116
|
+
self._store.add(Quad(node, PRED_TYPE, TYPE_ENTITY, GRAPH_KNOWLEDGE))
|
|
117
|
+
|
|
118
|
+
# Store each data field as a separate quad
|
|
119
|
+
# Non-string values are JSON-serialized as string literals
|
|
120
|
+
for key, value in data.items():
|
|
121
|
+
pred = NamedNode(f"urn:aevum:field:{key}")
|
|
122
|
+
if isinstance(value, str):
|
|
123
|
+
obj: Literal = _lit_str(value)
|
|
124
|
+
elif isinstance(value, int):
|
|
125
|
+
obj = _lit_int(value)
|
|
126
|
+
elif isinstance(value, float):
|
|
127
|
+
obj = Literal(str(value), datatype=NamedNode(
|
|
128
|
+
"http://www.w3.org/2001/XMLSchema#double"
|
|
129
|
+
))
|
|
130
|
+
else:
|
|
131
|
+
obj = _lit_str(json.dumps(value))
|
|
132
|
+
self._store.add(Quad(node, pred, obj, GRAPH_KNOWLEDGE))
|
|
133
|
+
|
|
134
|
+
# Store provenance in urn:aevum:provenance
|
|
135
|
+
# (separate quads — not RDF-star, which is dropped in 0.5)
|
|
136
|
+
prov_quads = [
|
|
137
|
+
Quad(node, PRED_SUBJECT_ID, _lit_str(entity_id), GRAPH_PROVENANCE),
|
|
138
|
+
Quad(node, PRED_CLASS_LVL, _lit_int(classification), GRAPH_PROVENANCE),
|
|
139
|
+
Quad(node, PRED_INGEST_AT, _lit_dt(now_iso), GRAPH_PROVENANCE),
|
|
140
|
+
]
|
|
141
|
+
for q in prov_quads:
|
|
142
|
+
self._store.add(q)
|
|
143
|
+
|
|
144
|
+
def get_entity(self, entity_id: str) -> dict[str, Any] | None:
|
|
145
|
+
"""Retrieve an entity by ID. Returns None if not found."""
|
|
146
|
+
node = _entity_node(entity_id)
|
|
147
|
+
result: dict[str, Any] = {}
|
|
148
|
+
|
|
149
|
+
with self._lock:
|
|
150
|
+
quads = list(self._store.quads_for_pattern(
|
|
151
|
+
node, None, None, GRAPH_KNOWLEDGE
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
if not quads:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
for quad in quads:
|
|
158
|
+
pred_str = quad.predicate.value
|
|
159
|
+
if pred_str == PRED_TYPE.value:
|
|
160
|
+
continue # skip rdf:type
|
|
161
|
+
if pred_str.startswith("urn:aevum:field:"):
|
|
162
|
+
key = pred_str[len("urn:aevum:field:"):]
|
|
163
|
+
obj = quad.object
|
|
164
|
+
if isinstance(obj, (NamedNode, Literal)):
|
|
165
|
+
raw = obj.value
|
|
166
|
+
try:
|
|
167
|
+
result[key] = json.loads(raw)
|
|
168
|
+
except (json.JSONDecodeError, ValueError):
|
|
169
|
+
result[key] = raw
|
|
170
|
+
|
|
171
|
+
return result if result else None
|
|
172
|
+
|
|
173
|
+
def get_entity_classification(self, entity_id: str) -> int:
|
|
174
|
+
"""Return the classification level of an entity (default 0)."""
|
|
175
|
+
node = _entity_node(entity_id)
|
|
176
|
+
with self._lock:
|
|
177
|
+
quads = list(self._store.quads_for_pattern(
|
|
178
|
+
node, PRED_CLASS_LVL, None, GRAPH_PROVENANCE
|
|
179
|
+
))
|
|
180
|
+
if not quads:
|
|
181
|
+
return 0
|
|
182
|
+
obj = quads[0].object
|
|
183
|
+
if not isinstance(obj, (NamedNode, Literal)):
|
|
184
|
+
return 0
|
|
185
|
+
try:
|
|
186
|
+
return int(obj.value)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
def query_entities(
|
|
191
|
+
self,
|
|
192
|
+
subject_ids: list[str],
|
|
193
|
+
classification_max: int = 0,
|
|
194
|
+
) -> dict[str, dict[str, Any]]:
|
|
195
|
+
"""
|
|
196
|
+
Retrieve entities for the given subject IDs.
|
|
197
|
+
Excludes entities classified above classification_max (Barrier 2).
|
|
198
|
+
"""
|
|
199
|
+
result: dict[str, dict[str, Any]] = {}
|
|
200
|
+
for entity_id in subject_ids:
|
|
201
|
+
entity_class = self.get_entity_classification(entity_id)
|
|
202
|
+
if entity_class > classification_max:
|
|
203
|
+
continue # Barrier 2: classification ceiling
|
|
204
|
+
entity_data = self.get_entity(entity_id)
|
|
205
|
+
if entity_data is not None:
|
|
206
|
+
result[entity_id] = entity_data
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
def sparql_select(
|
|
210
|
+
self,
|
|
211
|
+
query: str,
|
|
212
|
+
default_graph: str | None = None,
|
|
213
|
+
) -> list[dict[str, Any]]:
|
|
214
|
+
"""
|
|
215
|
+
Execute a SPARQL SELECT query and return results as a list of dicts.
|
|
216
|
+
|
|
217
|
+
Not part of the GraphStore Protocol. Useful for ad-hoc traversal
|
|
218
|
+
within complications or diagnostics. Not exposed as a public HTTP endpoint.
|
|
219
|
+
"""
|
|
220
|
+
kwargs: dict[str, Any] = {}
|
|
221
|
+
if default_graph:
|
|
222
|
+
kwargs["default_graph"] = NamedNode(default_graph)
|
|
223
|
+
with self._lock:
|
|
224
|
+
solutions = self._store.query(query, **kwargs)
|
|
225
|
+
rows: list[dict[str, Any]] = []
|
|
226
|
+
if not isinstance(solutions, QuerySolutions):
|
|
227
|
+
return rows
|
|
228
|
+
for sol in solutions:
|
|
229
|
+
row: dict[str, Any] = {}
|
|
230
|
+
for var in sol.variables():
|
|
231
|
+
term = sol[var]
|
|
232
|
+
if term is not None and isinstance(term, (NamedNode, Literal)):
|
|
233
|
+
row[str(var)] = term.value
|
|
234
|
+
rows.append(row)
|
|
235
|
+
return rows
|
|
236
|
+
|
|
237
|
+
def entity_count(self) -> int:
|
|
238
|
+
"""Return number of distinct entities in urn:aevum:knowledge."""
|
|
239
|
+
with self._lock:
|
|
240
|
+
quads = list(self._store.quads_for_pattern(
|
|
241
|
+
None, PRED_TYPE, TYPE_ENTITY, GRAPH_KNOWLEDGE
|
|
242
|
+
))
|
|
243
|
+
return len(quads)
|
|
244
|
+
|
|
245
|
+
def clear_knowledge_graph(self) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Clear all entities from urn:aevum:knowledge and urn:aevum:provenance.
|
|
248
|
+
For testing only — not available via any public API.
|
|
249
|
+
"""
|
|
250
|
+
with self._lock:
|
|
251
|
+
self._store.clear_graph(GRAPH_KNOWLEDGE)
|
|
252
|
+
self._store.clear_graph(GRAPH_PROVENANCE)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standard relationship vocabulary for the Aevum knowledge graph.
|
|
3
|
+
|
|
4
|
+
These predicates are used when storing entities and their relationships.
|
|
5
|
+
They are not exposed as a user-facing SPARQL endpoint (Non-Goal per spec 3.4).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pyoxigraph import NamedNode
|
|
11
|
+
|
|
12
|
+
# Aevum namespace
|
|
13
|
+
AEVUM = "https://aevum.build/vocab/"
|
|
14
|
+
|
|
15
|
+
# Core predicates
|
|
16
|
+
PRED_TYPE = NamedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")
|
|
17
|
+
PRED_LABEL = NamedNode("http://www.w3.org/2000/01/rdf-schema#label")
|
|
18
|
+
PRED_CONTENT = NamedNode(f"{AEVUM}content")
|
|
19
|
+
PRED_SUBJECT_ID = NamedNode(f"{AEVUM}subjectId")
|
|
20
|
+
PRED_SOURCE_ID = NamedNode(f"{AEVUM}sourceId")
|
|
21
|
+
PRED_AUDIT_ID = NamedNode(f"{AEVUM}auditId")
|
|
22
|
+
PRED_CLASS_LVL = NamedNode(f"{AEVUM}classificationLevel")
|
|
23
|
+
PRED_INGEST_AT = NamedNode(f"{AEVUM}ingestedAt")
|
|
24
|
+
|
|
25
|
+
# Entity types
|
|
26
|
+
TYPE_ENTITY = NamedNode(f"{AEVUM}Entity")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration test: Engine using OxigraphStore instead of InMemoryGraphStore.
|
|
3
|
+
Verifies the real backend works end-to-end with the kernel.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from aevum.core import Engine
|
|
9
|
+
from aevum.core.consent.models import ConsentGrant
|
|
10
|
+
|
|
11
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _engine() -> Engine:
|
|
15
|
+
store = OxigraphStore()
|
|
16
|
+
engine = Engine(graph_store=store)
|
|
17
|
+
engine.add_consent_grant(ConsentGrant(
|
|
18
|
+
grant_id="g1",
|
|
19
|
+
subject_id="subject-1",
|
|
20
|
+
grantee_id="actor",
|
|
21
|
+
operations=["ingest", "query", "replay", "export"],
|
|
22
|
+
purpose="integration-test",
|
|
23
|
+
classification_max=3,
|
|
24
|
+
granted_at="2026-01-01T00:00:00Z",
|
|
25
|
+
expires_at="2030-01-01T00:00:00Z",
|
|
26
|
+
))
|
|
27
|
+
return engine
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _prov() -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"source_id": "test-src",
|
|
33
|
+
"chain_of_custody": ["test-src"],
|
|
34
|
+
"classification": 0,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_ingest_then_query_with_oxigraph() -> None:
|
|
39
|
+
engine = _engine()
|
|
40
|
+
ingest_result = engine.ingest(
|
|
41
|
+
data={"content": "hello world", "type": "text"},
|
|
42
|
+
provenance=_prov(),
|
|
43
|
+
purpose="integration-test",
|
|
44
|
+
subject_id="subject-1",
|
|
45
|
+
actor="actor",
|
|
46
|
+
)
|
|
47
|
+
assert ingest_result.status == "ok"
|
|
48
|
+
|
|
49
|
+
query_result = engine.query(
|
|
50
|
+
purpose="integration-test",
|
|
51
|
+
subject_ids=["subject-1"],
|
|
52
|
+
actor="actor",
|
|
53
|
+
)
|
|
54
|
+
assert query_result.status == "ok"
|
|
55
|
+
assert "subject-1" in query_result.data["results"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_classification_ceiling_enforced_via_engine() -> None:
|
|
59
|
+
"""Barrier 2 still applies when using OxigraphStore."""
|
|
60
|
+
engine = _engine()
|
|
61
|
+
engine.ingest(
|
|
62
|
+
data={"content": "public"},
|
|
63
|
+
provenance={**_prov(), "classification": 0},
|
|
64
|
+
purpose="integration-test",
|
|
65
|
+
subject_id="subject-1",
|
|
66
|
+
actor="actor",
|
|
67
|
+
)
|
|
68
|
+
# Query with classification_max=0 — should get results
|
|
69
|
+
r = engine.query(
|
|
70
|
+
purpose="integration-test",
|
|
71
|
+
subject_ids=["subject-1"],
|
|
72
|
+
actor="actor",
|
|
73
|
+
classification_max=0,
|
|
74
|
+
)
|
|
75
|
+
assert r.status == "ok"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_sigchain_intact_with_oxigraph() -> None:
|
|
79
|
+
"""Sigchain still works regardless of graph backend."""
|
|
80
|
+
engine = _engine()
|
|
81
|
+
for i in range(5):
|
|
82
|
+
engine.commit(event_type=f"app.event_{i}", payload={"i": i}, actor="actor")
|
|
83
|
+
assert engine.verify_sigchain() is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_demo_ten_lines() -> None:
|
|
87
|
+
"""
|
|
88
|
+
A developer can ingest data and query it back using OxigraphStore in ~10 lines.
|
|
89
|
+
"""
|
|
90
|
+
from aevum.core import Engine
|
|
91
|
+
from aevum.core.consent.models import ConsentGrant
|
|
92
|
+
|
|
93
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
94
|
+
|
|
95
|
+
store = OxigraphStore()
|
|
96
|
+
engine = Engine(graph_store=store)
|
|
97
|
+
engine.add_consent_grant(ConsentGrant(
|
|
98
|
+
grant_id="demo-grant", subject_id="user-42", grantee_id="demo-actor",
|
|
99
|
+
operations=["ingest", "query", "replay", "export"],
|
|
100
|
+
purpose="demo", classification_max=3,
|
|
101
|
+
granted_at="2026-01-01T00:00:00Z", expires_at="2030-01-01T00:00:00Z",
|
|
102
|
+
))
|
|
103
|
+
ingest = engine.ingest(
|
|
104
|
+
data={"name": "Alice", "role": "engineer"},
|
|
105
|
+
provenance={"source_id": "hr-system", "chain_of_custody": ["hr-system"],
|
|
106
|
+
"classification": 0},
|
|
107
|
+
purpose="demo", subject_id="user-42", actor="demo-actor",
|
|
108
|
+
)
|
|
109
|
+
assert ingest.status == "ok"
|
|
110
|
+
|
|
111
|
+
result = engine.query(
|
|
112
|
+
purpose="demo", subject_ids=["user-42"], actor="demo-actor"
|
|
113
|
+
)
|
|
114
|
+
assert result.status == "ok"
|
|
115
|
+
assert "user-42" in result.data["results"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for OxigraphStore — GraphStore Protocol conformance.
|
|
3
|
+
All tests run against in-memory store (no disk I/O in CI).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from aevum.core.protocols.graph_store import GraphStore
|
|
9
|
+
|
|
10
|
+
from aevum.store.oxigraph import OxigraphStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _store() -> OxigraphStore:
|
|
14
|
+
return OxigraphStore() # in-memory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_satisfies_graphstore_protocol() -> None:
|
|
18
|
+
"""OxigraphStore must satisfy the GraphStore Protocol at runtime."""
|
|
19
|
+
assert isinstance(_store(), GraphStore)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_store_and_get_entity() -> None:
|
|
23
|
+
s = _store()
|
|
24
|
+
s.store_entity("e1", {"content": "hello", "type": "text"})
|
|
25
|
+
result = s.get_entity("e1")
|
|
26
|
+
assert result is not None
|
|
27
|
+
assert result["content"] == "hello"
|
|
28
|
+
assert result["type"] == "text"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_get_nonexistent_entity_returns_none() -> None:
|
|
32
|
+
s = _store()
|
|
33
|
+
assert s.get_entity("does-not-exist") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_update_entity_replaces_data() -> None:
|
|
37
|
+
s = _store()
|
|
38
|
+
s.store_entity("e1", {"content": "original"})
|
|
39
|
+
s.store_entity("e1", {"content": "updated"})
|
|
40
|
+
result = s.get_entity("e1")
|
|
41
|
+
assert result is not None
|
|
42
|
+
assert result["content"] == "updated"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_query_entities_returns_matching() -> None:
|
|
46
|
+
s = _store()
|
|
47
|
+
s.store_entity("s1", {"content": "data1"})
|
|
48
|
+
s.store_entity("s2", {"content": "data2"})
|
|
49
|
+
results = s.query_entities(["s1", "s2"])
|
|
50
|
+
assert "s1" in results
|
|
51
|
+
assert "s2" in results
|
|
52
|
+
assert results["s1"]["content"] == "data1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_query_entities_absent_returns_empty() -> None:
|
|
56
|
+
s = _store()
|
|
57
|
+
results = s.query_entities(["not-here"])
|
|
58
|
+
assert results == {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_classification_ceiling_barrier2() -> None:
|
|
62
|
+
"""Barrier 2: entities above classification_max are excluded."""
|
|
63
|
+
s = _store()
|
|
64
|
+
s.store_entity("public-data", {"content": "public"}, classification=0)
|
|
65
|
+
s.store_entity("secret-data", {"content": "secret"}, classification=3)
|
|
66
|
+
|
|
67
|
+
# classification_max=0: only public data returned
|
|
68
|
+
results = s.query_entities(["public-data", "secret-data"], classification_max=0)
|
|
69
|
+
assert "public-data" in results
|
|
70
|
+
assert "secret-data" not in results
|
|
71
|
+
|
|
72
|
+
# classification_max=3: both returned
|
|
73
|
+
results = s.query_entities(["public-data", "secret-data"], classification_max=3)
|
|
74
|
+
assert "public-data" in results
|
|
75
|
+
assert "secret-data" in results
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_classification_stored_and_retrieved() -> None:
|
|
79
|
+
s = _store()
|
|
80
|
+
s.store_entity("classified", {"content": "sensitive"}, classification=2)
|
|
81
|
+
assert s.get_entity_classification("classified") == 2
|
|
82
|
+
assert s.get_entity_classification("unknown") == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_entity_count() -> None:
|
|
86
|
+
s = _store()
|
|
87
|
+
assert s.entity_count() == 0
|
|
88
|
+
s.store_entity("e1", {"x": 1})
|
|
89
|
+
s.store_entity("e2", {"x": 2})
|
|
90
|
+
assert s.entity_count() == 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_integer_values_round_trip() -> None:
|
|
94
|
+
s = _store()
|
|
95
|
+
s.store_entity("e1", {"count": 42, "label": "test"})
|
|
96
|
+
result = s.get_entity("e1")
|
|
97
|
+
assert result is not None
|
|
98
|
+
assert result["count"] == 42
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_three_named_graphs_exist() -> None:
|
|
102
|
+
"""The three Named Graph URIs must be present after store init."""
|
|
103
|
+
from aevum.store.oxigraph.store import GRAPH_CONSENT, GRAPH_KNOWLEDGE, GRAPH_PROVENANCE
|
|
104
|
+
s = _store()
|
|
105
|
+
graphs = {str(g) for g in s._store.named_graphs()}
|
|
106
|
+
assert str(GRAPH_KNOWLEDGE) in graphs
|
|
107
|
+
assert str(GRAPH_PROVENANCE) in graphs
|
|
108
|
+
assert str(GRAPH_CONSENT) in graphs
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_clear_does_not_affect_consent_graph() -> None:
|
|
112
|
+
"""clear_knowledge_graph must not touch urn:aevum:consent."""
|
|
113
|
+
from aevum.store.oxigraph.store import GRAPH_CONSENT
|
|
114
|
+
s = _store()
|
|
115
|
+
s.store_entity("e1", {"content": "data"})
|
|
116
|
+
s.clear_knowledge_graph()
|
|
117
|
+
assert s.get_entity("e1") is None
|
|
118
|
+
# Consent graph still exists
|
|
119
|
+
graphs = {str(g) for g in s._store.named_graphs()}
|
|
120
|
+
assert str(GRAPH_CONSENT) in graphs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_thread_safety() -> None:
|
|
124
|
+
"""Concurrent writes must not corrupt the store."""
|
|
125
|
+
import threading
|
|
126
|
+
s = _store()
|
|
127
|
+
errors: list[str] = []
|
|
128
|
+
|
|
129
|
+
def write(i: int) -> None:
|
|
130
|
+
try:
|
|
131
|
+
s.store_entity(f"entity-{i}", {"value": i})
|
|
132
|
+
except Exception as e:
|
|
133
|
+
errors.append(str(e))
|
|
134
|
+
|
|
135
|
+
threads = [threading.Thread(target=write, args=(i,)) for i in range(20)]
|
|
136
|
+
for t in threads:
|
|
137
|
+
t.start()
|
|
138
|
+
for t in threads:
|
|
139
|
+
t.join()
|
|
140
|
+
|
|
141
|
+
assert errors == [], f"Thread safety errors: {errors}"
|
|
142
|
+
assert s.entity_count() == 20
|