Skip to content

Commit a3ee623

Browse files
jpheinclaude
andcommitted
feat(kg): KnowledgeGraphAGE skeleton + graph bootstrap
First commit of the AGE-backed knowledge graph layer. Skeleton class in `mempalace/knowledge_graph_age.py` that opens a Postgres connection, loads the AGE extension, sets `search_path = ag_catalog, "$user", public` for the session, and creates a graph named `mempalace_kg` in `ag_catalog.ag_graph` if one doesn't already exist. Idempotent bootstrap; safe to instantiate repeatedly. Three `pytest.skipif`-gated integration tests in `tests/test_knowledge_graph_age.py`: - `test_age_kg_instantiates` — class constructs cleanly, closes without exception. - `test_age_graph_created` — `mempalace_kg` is registered in `ag_catalog.ag_graph` with a non-null `graphid` after init. - `test_age_context_manager` — `with KnowledgeGraphAGE(...) as kg:` pattern closes the connection on exit (verifies `_conn.closed` is True after). Verified end-to-end against the live mempalace-db container on disks.jphe.in:5433 (PG16 + pgvector 0.8.2 + AGE 1.6.0): all 3 AGE tests pass; full suite 1854 passed / 1 skipped / 106 deselected with TEST_POSTGRES_DSN set — zero regressions vs the 1851 pre-AGE baseline (+3 AGE tests). Adapted plan code from psycopg3 to psycopg2 for consistency with MemPalace#665's dependency choice (psycopg2-binary is what `pip install -e ".[postgres]"` brings in; introducing psycopg3 alongside would duplicate the driver surface for no real gain at this stage). Future commits in this layer: triple add/query operations, temporal filtering (`as_of` queries), and a `MempalaceConfig.kg_backend` routing flag selecting AGE over the SQLite default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6552dd9 commit a3ee623

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

mempalace/knowledge_graph_age.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""AGE-backed implementation of KnowledgeGraph (Apache AGE on Postgres).
2+
3+
Companion to `mempalace.knowledge_graph.KnowledgeGraph` (SQLite). Selectable
4+
via `MEMPALACE_KG_BACKEND=age` once the config-routing layer is wired up.
5+
Mirrors the public interface of the SQLite KG so callers can swap backends
6+
without code changes.
7+
8+
The graph itself is `mempalace_kg` registered in AGE's `ag_catalog`. It is
9+
created on first init and reused thereafter — initialization is idempotent.
10+
"""
11+
12+
import psycopg2
13+
14+
15+
class KnowledgeGraphAGE:
16+
"""Cypher-queryable KG using Apache AGE on a Postgres connection.
17+
18+
Currently a skeleton: instantiation + graph bootstrap only. Triple
19+
add/query operations and temporal filtering arrive in subsequent
20+
commits; the routing layer that lets `MempalaceConfig.kg_backend`
21+
select this backend over the SQLite one is its own change.
22+
"""
23+
24+
GRAPH_NAME = "mempalace_kg"
25+
26+
def __init__(self, dsn: str):
27+
"""Open a Postgres connection and ensure `mempalace_kg` exists.
28+
29+
Args:
30+
dsn: PostgreSQL DSN. Must point at a database where the AGE
31+
extension is installed (CREATE EXTENSION succeeds). The
32+
``apache/age:release_PG16_1.6.0`` image we deploy on the
33+
homelab already has the .so files baked in; bare-metal
34+
Postgres requires source-build of AGE first.
35+
"""
36+
self._conn = psycopg2.connect(dsn)
37+
# KG writes need explicit commit semantics, not autocommit — keep
38+
# the same shape as the SQLite KG so the eventual unified write
39+
# API can be swapped underneath. The bootstrap below commits its
40+
# own changes; subsequent write operations control their own
41+
# transactions.
42+
self._conn.autocommit = False
43+
self._ensure_graph()
44+
45+
def _ensure_graph(self) -> None:
46+
"""Idempotent: load AGE, set search_path, create the graph if absent.
47+
48+
Both `LOAD 'age'` and the `search_path` setting are session-scoped
49+
— anything new that takes a fresh cursor on this connection must
50+
re-run them before issuing Cypher. The pattern in this module is
51+
to always wrap `LOAD 'age'; SET search_path = ag_catalog, "$user",
52+
public` around each cypher block; for the bootstrap that's done
53+
here, downstream methods will repeat as needed.
54+
"""
55+
with self._conn.cursor() as cur:
56+
cur.execute("CREATE EXTENSION IF NOT EXISTS age")
57+
cur.execute("LOAD 'age'")
58+
cur.execute('SET search_path = ag_catalog, "$user", public')
59+
cur.execute(
60+
"SELECT graphid FROM ag_catalog.ag_graph WHERE name = %s",
61+
(self.GRAPH_NAME,),
62+
)
63+
if cur.fetchone() is None:
64+
cur.execute("SELECT create_graph(%s)", (self.GRAPH_NAME,))
65+
self._conn.commit()
66+
67+
def close(self) -> None:
68+
"""Close the underlying Postgres connection."""
69+
if self._conn and not self._conn.closed:
70+
self._conn.close()
71+
72+
def __enter__(self) -> "KnowledgeGraphAGE":
73+
return self
74+
75+
def __exit__(self, exc_type, exc, tb) -> None:
76+
self.close()

tests/test_knowledge_graph_age.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Integration tests for the AGE-backed KnowledgeGraph implementation.
2+
3+
Requires a live Postgres with Apache AGE installed. Set TEST_POSTGRES_DSN
4+
to point at one (e.g. the homelab `mempalace-db` container documented in
5+
`scratch/postgres-preflight-2026-05-10.md`); skipped by default so the
6+
suite stays green on machines without a postgres at hand.
7+
8+
Pairs with `mempalace/knowledge_graph_age.py`. The classic SQLite-backed
9+
`KnowledgeGraph` in `mempalace/knowledge_graph.py` stays the default;
10+
the AGE backend is opt-in via `MEMPALACE_KG_BACKEND=age` once the
11+
config-routing layer is wired.
12+
"""
13+
14+
import os
15+
16+
import pytest
17+
18+
POSTGRES_DSN = os.environ.get("TEST_POSTGRES_DSN")
19+
pytestmark = pytest.mark.skipif(
20+
POSTGRES_DSN is None,
21+
reason="set TEST_POSTGRES_DSN to run AGE knowledge-graph tests",
22+
)
23+
24+
25+
def test_age_kg_instantiates():
26+
"""KnowledgeGraphAGE opens a connection and exits cleanly."""
27+
from mempalace.knowledge_graph_age import KnowledgeGraphAGE
28+
29+
kg = KnowledgeGraphAGE(dsn=POSTGRES_DSN)
30+
assert kg is not None
31+
kg.close()
32+
33+
34+
def test_age_graph_created():
35+
"""`mempalace_kg` graph is registered in AGE's catalog after init."""
36+
from mempalace.knowledge_graph_age import KnowledgeGraphAGE
37+
38+
kg = KnowledgeGraphAGE(dsn=POSTGRES_DSN)
39+
try:
40+
with kg._conn.cursor() as cur:
41+
cur.execute(
42+
"SELECT graphid FROM ag_catalog.ag_graph WHERE name = %s",
43+
(KnowledgeGraphAGE.GRAPH_NAME,),
44+
)
45+
row = cur.fetchone()
46+
assert row is not None, "mempalace_kg graph should exist after init"
47+
assert row[0] is not None, "graph should have a non-null graphid"
48+
finally:
49+
kg.close()
50+
51+
52+
def test_age_context_manager():
53+
"""`with KnowledgeGraphAGE(...) as kg:` closes the conn on exit."""
54+
from mempalace.knowledge_graph_age import KnowledgeGraphAGE
55+
56+
with KnowledgeGraphAGE(dsn=POSTGRES_DSN) as kg:
57+
assert kg._conn is not None
58+
assert not kg._conn.closed
59+
# After the with block, the connection should be closed.
60+
assert kg._conn.closed

0 commit comments

Comments
 (0)