Backend parity & deployment-profile testing¶
Agent-utilities ships the same code to a Raspberry Pi 3 and to an enterprise cluster. The epistemic-graph engine is the one database in every case; the optional mirrors (Postgres/pg-age, Neo4j, FalkorDB, Ladybug) must each accept the engine's async write fan-out faithfully. Two test layers keep that promise honest:
- Mirror conformance — one assertion body run against every supported
mirror (
tests/integration/backends/). - Deployment profiles — two end-to-end topologies: a zero-dependency "tiny"
profile (engine only) and the full "enterprise" profile, engine + mirrors
(
tests/integration/profiles/).
Both stand real services up in throwaway containers via
testcontainers — on random
free ports, torn down deterministically — so a run is hermetic and can be
intentionally broken without touching anything you care about.
Install¶
Requires a reachable Docker daemon for the live matrix. The zero-infra case
(the embedded epistemic-graph engine, no mirrors) needs neither Docker nor
testcontainers.
What runs when¶
| Selection | What runs | Needs Docker? |
|---|---|---|
pytest (default, -m "not live") |
tiny-profile (engine-only) zero-dep e2e; mirror conformance for ladybug |
no |
pytest -m live |
full mirror matrix (pg-age/Neo4j/FalkorDB) + Fuseki SPARQL + enterprise-profile e2e | yes |
The default PR suite therefore continuously enforces the Pi-3 zero-dependency
contract (including a cold-import footprint guard) without requiring Docker, and
the heavyweight cross-backend/cluster checks run under -m live and on the
nightly job.
# Pi-3 guarantee (fast, no Docker)
pytest tests/integration/profiles/test_profile_tiny_zero_dep.py -v
# zero-infra conformance params (no Docker)
pytest tests/integration/backends/test_backend_conformance.py -v
# full live matrix + enterprise profile (Docker required)
pytest tests/integration/backends -m live -v
pytest tests/integration/profiles/test_profile_enterprise_full.py -m live -v
The KG/engine assertions skip when the local epistemic-graph engine isn't running
(GRAPH_SERVICE_SOCKET unset, e.g. a polyrepo CI without the Rust source); the
footprint guard always runs.
The two profiles¶
Tiny (Raspberry Pi 3). GRAPH_BACKEND=epistemic_graph (the engine alone — the
one self-contained database, no mirrors), OWL_BACKEND=owlready2, SQLite task
queue, inline dispatch, no GRAPH_DB_URI/STATE_DB_URI/Kafka. The test boots the
gateway REST surface in-process and asserts write→query works and the local OWL
reasoner runs — with zero containers — plus a subprocess cold-import check
that no mirror/external-service driver
(aiokafka/psycopg/neo4j/falkordb/pystardog/confluent_kafka) leaked
into the footprint.
Enterprise. Engine + throwaway pg-age + Kafka + Fuseki
(GRAPH_BACKEND=fanout). Asserts the three integration seams: writes committed to
the engine fan out and land in the pg-age mirror, surviving a reconnect; the
task queue resolves to Kafka and a put→consume→ack round-trips; the ontology
publishes to Fuseki and is queryable over SPARQL.
Parity status (the 100%-parity program)¶
A live full-matrix probe (write via the engine, then read each mirror via
backend.execute, all mirrors running) drove a phased program that closed the
gaps. The epistemic_graph column is the authority; the rest are mirrors.
Verified current state:
| Capability | epistemic_graph (authority) | ladybug | pg-age (AGE) | neo4j | falkordb |
|---|---|---|---|---|---|
| node props (declared/ad-hoc/nested) | ✅ | ✅ (ad-hoc in metadata) |
✅ | ✅ | ✅ |
| edge existence | ✅ | ✅ | ✅ | ✅ | ✅ |
| edge properties | ✅ | ✅ (JSON r.properties) |
✅ | ✅ | ✅ |
| full Cypher (count/alias/multi-hop) | subset¹ | ✅ (Kuzu) | ✅ (AGE) | ✅ | ✅ |
| vector search | ✅ | ✅ | ✅ (pgvector) | ✅ (:Embeddable) |
⚠️² |
| SPARQL (via OWL/RDF layer) | ✅ local /sparql |
✅ | ✅ | ✅ | ✅ |
¹ epistemic_graph is the authority engine; its backend.execute
interprets an operational Cypher subset (id-anchored traversals), and multi-hop
traversal is served via the engine's native compute layer — by design, not a gap.
² FalkorDB vector search is code-correct (Cypher DDL CREATE VECTOR INDEX +
db.idx.vector.queryNodes, verified with small vectors) but the
falkordb/falkordb image crashes (SIGILL) on 768-dim vector ops on non-AVX
host CPUs — verify on AVX-capable hardware.
What changed:
- Neo4j/FalkorDB are first-class mirrors — they crashed on the standard write
path (label()), threw on nested props, and mis-targeted the vector index; all
fixed. They run in the -m live conformance matrix and pass the contract.
- pg-age runs Apache AGE (GRAPH_PG_AGE=1 / backend_type=age,
docker/pg-age-age.compose.yml) — real openCypher incl. count(r), multi-hop,
variable-length, edge props, plus pgvector embeddings.
- Edge properties persist on every mirror (Ladybug via a JSON r.properties
column on REL tables).
- Local SPARQL is served at {prefix}/sparql over the OWL/RDF bridge (rdflib
materialization) with zero external deps — Fuseki/Stardog are optional scale-out.
Remaining gaps (surfaced, not hidden)¶
The conformance suite skips with a backend-named reason rather than silently
passing where a backend genuinely can't satisfy a check:
- FalkorDB vector search needs an AVX-capable host (see ² above).
- Prune semantics differ (importance vs
last_accessed); the suite asserts only the shared no-raise contract. - Per-mirror ontology object/link/function parity is exercised through the tiny-profile gateway path against the default engine-only backend.
Adopting this in another agent-package¶
Any agents/* package can copy the testcontainers fixture pattern (it supersedes
the hand-rolled compose.test.yml approach in e.g. vector-mcp). A minimal
conftest.py:
import pytest
@pytest.fixture(scope="session")
def ephemeral_pg():
pytest.importorskip("testcontainers")
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:16") as pg:
host, port = pg.get_container_host_ip(), pg.get_exposed_port(5432)
yield f"postgresql://test:test@{host}:{port}/test"
@pytest.mark.live
def test_against_real_pg(ephemeral_pg):
... # build the client from the URI, run real assertions
Conventions to keep parity with this package:
- import testcontainers and drivers lazily inside fixtures so collection
works on a minimal install;
- mark container tests @pytest.mark.live and keep a zero-dep path in the default
suite;
- let testcontainers pick the port — never bind the canonical homelab port.
## The REAL ephemeral engine in tests — `tiny_engine` / `engine_graph` (CONCEPT:AU-KG.memory.provides-real-ephemeral-one)
Engine-backed tests validate against the **ACTUAL database we ship** — never
SQLite, never a mock — deployed ephemerally and destroyed afterwards. Two
first-class fixtures in `tests/conftest.py` (backed by `tests/_test_engine.py`)
own this:
- **`tiny_engine`** (session-scoped) — deploys **ONE** real
`epistemic-graph-server` for the whole session. It resolves the binary in
order: the prebuilt **wheel** binary (next to `sys.executable`) → the sibling
`epistemic-graph` checkout's `target/release`/`target/debug` → otherwise it
**builds the lean `pi`-tier binary once** (`cargo build --release
--no-default-features --features pi`) and caches it. The engine starts on an
**isolated ephemeral UDS socket** under a unique temp dir, with an isolated
temp `--persist-dir`, a test `GRAPH_SERVICE_AUTH_SECRET`, and
`--idle-shutdown-secs 120` (so a crashed suite self-reaps). It exports
`GRAPH_SERVICE_SOCKET` (+ the secret) so the client / `EngineResolver` connect
to **this** engine via the *shared* leg (CONCEPT:AU-OS.deployment.engine-resolver-auto-provision — no autostart).
Teardown is a graceful **SIGTERM** (the engine checkpoints + exits cleanly,
CONCEPT:EG-KG.backend.tiny-shared), then the temp persist dir + socket are removed — zero
residue. If no binary AND no Rust toolchain exist, it `skip`s with a clear
message; an externally-provided `GRAPH_SERVICE_SOCKET` (a shared host engine)
is reused verbatim.
- **`engine_graph`** (function-scoped) — gives each test a **fresh, isolated
tenant graph** on the session engine: a uniquely-named tenant
(`GraphComputeEngine(graph_name=…)` auto-creates it) is yielded, then
**tenant-purged** (CONCEPT:EG-KG.backend.tenant-delete-recreate-same) on teardown so per-test state never leaks.
This is fast isolation — one engine process, a fresh graph per test — not a new
process per test.
Opt a test into the real DB by **requesting `engine_graph`** (or marking it
`@pytest.mark.engine`). Example:
```python
import pytest
pytestmark = pytest.mark.engine
def test_node_roundtrip(engine_graph):
engine_graph.add_node("alpha", {"type": "Agent", "score": 7})
assert engine_graph.has_node("alpha")
assert engine_graph._client.nodes.properties("alpha")["score"] == 7
The consolidation seam. Once this fixture set lands, the consolidation lanes
drop their SQLite fallbacks entirely and route to the engine unconditionally:
they connect through the same GRAPH_SERVICE_SOCKET / GraphComputeEngine path
the fixtures wire, so an engine-mode consolidation test just requests
engine_graph and runs against the real durable engine (an integration
follow-up).