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.
@@ -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"]
@@ -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