From c551476d8520738d6520a503cf8d08f1f7f0151e Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Wed, 19 Aug 2026 07:48:43 -0400 Subject: [PATCH 1/2] feat: knowledge-graph resource + observe identity provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with @memmesh/sdk v0.9.0 (thinkfleet-memory-sdk#21). New `mm.memory.graph` — stats, list_entities, get_entity, list_edges, traverse, on both the sync and async clients. There was no graph surface before, so the structural half of memory was unreachable from Python. Read-only on purpose: entities and edges are written by extraction during observe(), and exposing the manual create/retire routes would invite hand-maintained graphs, which is the work the engine exists to do. Prefer graph.stats() over len(list_entities()) for any size question — the list routes page, so their length is the page size, not the total. Against a real project that is 1000 vs 12142. observe() now forwards user_id / agent_id / session_id. The server route has always accepted them; the SDK was dropping them, so provenance never arrived. Omitted rather than sent as null, so an existing call site produces byte-identical requests. They are provenance, NOT a tenancy boundary: search filters `chatIdentityId IS NULL OR = $1`, permissive by design. Verified live against app.memmesh.ai: all five graph methods return real data (12142 entities / 287698 edges). 136 tests pass, 10 new. --- README.md | 29 +++- src/memmesh/__init__.py | 8 ++ src/memmesh/resources/__init__.py | 3 + src/memmesh/resources/graph.py | 220 ++++++++++++++++++++++++++++++ src/memmesh/resources/memory.py | 37 ++++- src/memmesh/types.py | 43 ++++++ tests/test_graph.py | 177 ++++++++++++++++++++++++ 7 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 src/memmesh/resources/graph.py create mode 100644 tests/test_graph.py diff --git a/README.md b/README.md index 09957a3..bae6514 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,11 @@ from memmesh import MemMesh, subject mm = MemMesh(api_key="sk-...", project_id="proj_...") # 1 — Observe: feed it the raw turn; the engine's noise filter decides what to keep -res = mm.observe(text="Moved to the annual plan, prefers email over SMS.") +res = mm.observe( + text="Moved to the annual plan, prefers email over SMS.", + user_id="user_42", # provenance on whatever the engine keeps + session_id="thread_7", # keeps a conversation's turns linkable +) print(res.saved, res.candidate_count) # filler comes back as saved == [] # 2 — Recall: hybrid semantic + keyword search @@ -50,12 +54,35 @@ asyncio.run(main()) | Area | Methods | |------|---------| | **Memory** | `observe` · `create` · `search` · `list` · `update` · `delete` · `stats` · `feedback` | +| **Knowledge graph** (`mm.memory.graph`) | `stats` · `list_entities` · `get_entity` · `list_edges` · `traverse` | | **Prediction** (`mm.lattice`) | `predict` · `mine` · `profile` · `predict_by_cohort` · `calibration` | Every method accepts an optional `project_id=` to override the client default, and raises a typed error (`AuthenticationError`, `RateLimitError`, `ValidationError`, …) on failure. 429 and 5xx are retried with backoff. +## Knowledge graph + +Observing doesn't only produce embeddable rows — extraction also resolves +entities and writes typed edges between them. That graph reaches facts no single +memory states outright. + +```python +# How much of what you remember made it into the graph? +st = mm.memory.graph.stats() +print(st["entityCount"], st["edgeCount"], st["memoriesWithEdges"]) + +# Multi-hop: who does Sarah ultimately report to? +sarah, = mm.memory.graph.list_entities(search="Sarah", limit=1) +chain = mm.memory.graph.traverse(sarah["id"], hops=2, predicates=["member_of", "led_by"]) +``` + +Use `stats()` — not `len(list_entities())` — for any "how big is it" question: +the list routes page, so their length is the page size, not the total. + +Read-only. Entities and edges are written by extraction during `observe()`; a +hand-maintained graph is the work the engine exists to do for you. + ## Configuration ```python diff --git a/src/memmesh/__init__.py b/src/memmesh/__init__.py index c1661e3..31a8908 100644 --- a/src/memmesh/__init__.py +++ b/src/memmesh/__init__.py @@ -37,6 +37,10 @@ ValidationError, ) from .types import ( + EntityWithEdges, + GraphStats, + MemoryEdge, + MemoryEntity, Accumulator, ActivityLevel, AlertDeliveryResult, @@ -166,6 +170,10 @@ ) __all__ = [ + "GraphStats", + "MemoryEntity", + "MemoryEdge", + "EntityWithEdges", "__version__", "MemMesh", "AsyncMemMesh", diff --git a/src/memmesh/resources/__init__.py b/src/memmesh/resources/__init__.py index 4c7251f..9488003 100644 --- a/src/memmesh/resources/__init__.py +++ b/src/memmesh/resources/__init__.py @@ -6,6 +6,7 @@ from .context import AsyncContextResource, ContextResource from .events import AsyncEventsResource, EventsResource from .financial import AsyncFinancialResource, FinancialResource +from .graph import AsyncGraphResource, GraphResource from .health import AsyncHealthResource, HealthResource from .lattice import AsyncLatticeResource, LatticeResource from .learning import AsyncLearningResource, LearningResource @@ -14,6 +15,8 @@ __all__ = [ "MemoryResource", + "GraphResource", + "AsyncGraphResource", "AsyncMemoryResource", "LatticeResource", "AsyncLatticeResource", diff --git a/src/memmesh/resources/graph.py b/src/memmesh/resources/graph.py new file mode 100644 index 0000000..bdb1cf7 --- /dev/null +++ b/src/memmesh/resources/graph.py @@ -0,0 +1,220 @@ +"""Knowledge-graph resource — the structural half of memory. + +Observing text doesn't only produce embeddable rows; extraction also resolves +entities and writes typed edges between them. That graph is what reaches a fact +no single memory states outright ("who does Sarah report to?" answered from +``sarah -[member_of]-> team`` plus ``team -[led_by]-> priya``). + +Both records are bi-temporal, and the two time axes mean different things: + +* ``valid_from`` / ``valid_to`` — when the fact was TRUE in the world. +* ``expired_at`` (edges) — when the graph stopped BELIEVING it, because a + contradicting edge superseded it. + +A fact that was true last year and a fact we were wrong about are not the same +thing, and collapsing them loses the audit trail. + +Read-only by design. Entities and edges are written by extraction when you +:meth:`~memmesh.resources.memory.MemoryResource.observe`; the server's manual +create/retire routes exist for annotation tooling, and exposing them here would +invite hand-maintained graphs — which is the work the engine exists to do. + +Mirrors ``@memmesh/sdk``'s ``resources/graph.ts``. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + +from ..types import EntityWithEdges, GraphStats, MemoryEdge, MemoryEntity + + +def _entity_params( + type: Optional[str], + scope: Optional[str], + search: Optional[str], + limit: Optional[int], + offset: Optional[int], +) -> dict: + params: dict = {} + if type is not None: + params["type"] = type + if scope is not None: + params["scope"] = scope + if search is not None: + params["search"] = search + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + return params + + +def _traverse_body( + entity_id: str, + hops: Optional[int], + predicates: Optional[List[str]], + as_of: Optional[str], +) -> dict: + body: dict = {"entityId": entity_id} + if hops is not None: + body["hops"] = hops + if predicates is not None: + body["predicates"] = predicates + if as_of is not None: + body["asOf"] = as_of + return body + + +class GraphResource: + """Synchronous knowledge-graph reads.""" + + def __init__(self, transport: Any) -> None: + self._t = transport + + def stats(self, *, project_id: Optional[str] = None) -> GraphStats: + """Aggregate counts for the whole graph. + + Prefer this over ``len(list_entities())`` for any "how big is it" + question: these are SQL ``COUNT(*)``s over the full table, where the + list routes page and would report the page size as the total. + """ + return self._t.get("/admin/memory/graph/stats", None, project_id) + + def list_entities( + self, + *, + type: Optional[str] = None, + scope: Optional[str] = None, + search: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + project_id: Optional[str] = None, + ) -> List[MemoryEntity]: + """Entities, filtered by type/scope or a substring of name or alias.""" + return self._t.get( + "/admin/memory/entities", + _entity_params(type, scope, search, limit, offset), + project_id, + ) + + def get_entity( + self, + entity_id: str, + *, + as_of: Optional[str] = None, + project_id: Optional[str] = None, + ) -> EntityWithEdges: + """One entity plus its 1-hop neighbourhood.""" + params = {"asOf": as_of} if as_of else None + return self._t.get(f"/admin/memory/entities/{entity_id}", params, project_id) + + def list_edges( + self, + *, + as_of: Optional[str] = None, + limit: Optional[int] = None, + project_id: Optional[str] = None, + ) -> List[MemoryEdge]: + """Every currently-valid edge. + + Use for rendering a whole small graph; for a large one, seed from an + entity and :meth:`traverse` instead. + """ + params: dict = {} + if as_of is not None: + params["asOf"] = as_of + if limit is not None: + params["limit"] = limit + return self._t.get("/admin/memory/graph/edges", params, project_id) + + def traverse( + self, + entity_id: str, + *, + hops: Optional[int] = None, + predicates: Optional[List[str]] = None, + as_of: Optional[str] = None, + project_id: Optional[str] = None, + ) -> List[MemoryEdge]: + """Walk out from a seed entity (1-3 hops). + + This is the multi-hop path: the edges returned here connect facts no + single memory states together, which is how a question gets answered + from a chain rather than from one lucky vector hit. + """ + return self._t.post( + "/admin/memory/graph/traverse", + _traverse_body(entity_id, hops, predicates, as_of), + project_id, + ) + + +class AsyncGraphResource: + """Async mirror of :class:`GraphResource`.""" + + def __init__(self, transport: Any) -> None: + self._t = transport + + async def stats(self, *, project_id: Optional[str] = None) -> GraphStats: + """Async mirror of :meth:`GraphResource.stats`.""" + return await self._t.get("/admin/memory/graph/stats", None, project_id) + + async def list_entities( + self, + *, + type: Optional[str] = None, + scope: Optional[str] = None, + search: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + project_id: Optional[str] = None, + ) -> List[MemoryEntity]: + """Async mirror of :meth:`GraphResource.list_entities`.""" + return await self._t.get( + "/admin/memory/entities", + _entity_params(type, scope, search, limit, offset), + project_id, + ) + + async def get_entity( + self, + entity_id: str, + *, + as_of: Optional[str] = None, + project_id: Optional[str] = None, + ) -> EntityWithEdges: + """Async mirror of :meth:`GraphResource.get_entity`.""" + params = {"asOf": as_of} if as_of else None + return await self._t.get(f"/admin/memory/entities/{entity_id}", params, project_id) + + async def list_edges( + self, + *, + as_of: Optional[str] = None, + limit: Optional[int] = None, + project_id: Optional[str] = None, + ) -> List[MemoryEdge]: + """Async mirror of :meth:`GraphResource.list_edges`.""" + params: dict = {} + if as_of is not None: + params["asOf"] = as_of + if limit is not None: + params["limit"] = limit + return await self._t.get("/admin/memory/graph/edges", params, project_id) + + async def traverse( + self, + entity_id: str, + *, + hops: Optional[int] = None, + predicates: Optional[List[str]] = None, + as_of: Optional[str] = None, + project_id: Optional[str] = None, + ) -> List[MemoryEdge]: + """Async mirror of :meth:`GraphResource.traverse`.""" + return await self._t.post( + "/admin/memory/graph/traverse", + _traverse_body(entity_id, hops, predicates, as_of), + project_id, + ) diff --git a/src/memmesh/resources/memory.py b/src/memmesh/resources/memory.py index 4e71e72..454c1bd 100644 --- a/src/memmesh/resources/memory.py +++ b/src/memmesh/resources/memory.py @@ -13,6 +13,7 @@ apaginate, paginate, ) +from .graph import AsyncGraphResource, GraphResource from ..types import ( ExplainResult, FeedbackRating, @@ -128,12 +129,30 @@ def _observe_body( return body -def _observe_text_body(text: str, role: str, occurred_at: Optional[str]) -> dict: +def _observe_text_body( + text: str, + role: str, + occurred_at: Optional[str], + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + session_id: Optional[str] = None, +) -> dict: """Body for the raw-text observe path — the engine's noise filter runs over - ``text`` and decides what (if anything) to keep.""" + ``text`` and decides what (if anything) to keep. + + The identity fields are provenance, recorded on whatever the engine keeps. + They are omitted rather than sent as null so a turn without them is + indistinguishable from one made by an older client. + """ body: dict = {"text": text, "role": role} if occurred_at: body["occurredAt"] = occurred_at + if user_id: + body["userId"] = user_id + if agent_id: + body["agentId"] = agent_id + if session_id: + body["sessionId"] = session_id return body @@ -162,6 +181,8 @@ class MemoryResource: def __init__(self, transport: Any) -> None: self._t = transport + #: Knowledge-graph reads — entities, edges, traversal, counts. + self.graph = GraphResource(transport) def observe( self, @@ -176,6 +197,9 @@ def observe( category: Optional[str] = None, activity_type: Optional[str] = None, occurred_at: Optional[str] = None, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + session_id: Optional[str] = None, metadata: Optional[dict] = None, project_id: Optional[str] = None, ) -> ObserveResponse: @@ -205,7 +229,7 @@ def observe( Either ``text`` (preferred) or ``content`` is required. """ if text is not None and text.strip(): - body = _observe_text_body(text, role, occurred_at) + body = _observe_text_body(text, role, occurred_at, user_id, agent_id, session_id) return _observe_response(self._t.post("/memory/observe", body, project_id)) if content is not None and content.strip(): body = _observe_body(content, subject, type, scope, importance, category, activity_type, occurred_at, metadata) @@ -587,6 +611,8 @@ class AsyncMemoryResource: def __init__(self, transport: Any) -> None: self._t = transport + #: Knowledge-graph reads — entities, edges, traversal, counts. + self.graph = AsyncGraphResource(transport) async def observe( self, @@ -601,6 +627,9 @@ async def observe( category: Optional[str] = None, activity_type: Optional[str] = None, occurred_at: Optional[str] = None, + user_id: Optional[str] = None, + agent_id: Optional[str] = None, + session_id: Optional[str] = None, metadata: Optional[dict] = None, project_id: Optional[str] = None, ) -> ObserveResponse: @@ -608,7 +637,7 @@ async def observe( turn through the engine's noise filter); ``content`` stays for legacy verbatim stores. Either ``text`` or ``content`` is required.""" if text is not None and text.strip(): - body = _observe_text_body(text, role, occurred_at) + body = _observe_text_body(text, role, occurred_at, user_id, agent_id, session_id) return _observe_response(await self._t.post("/memory/observe", body, project_id)) if content is not None and content.strip(): body = _observe_body(content, subject, type, scope, importance, category, activity_type, occurred_at, metadata) diff --git a/src/memmesh/types.py b/src/memmesh/types.py index 915b381..89943f4 100644 --- a/src/memmesh/types.py +++ b/src/memmesh/types.py @@ -130,6 +130,49 @@ class ExplainResult(TypedDict): sourceMemories: List[MemoryItem] +#: A knowledge-graph entity — a resolved thing (person, org, product, concept) +#: that extraction filed under a ``canonicalName``, with aliases resolving to it. +MemoryEntity = Dict[str, Any] + +#: A typed relationship between two entities, e.g. +#: ``{"subjectId": ..., "predicate": "works_at", "objectId": ...}``. The object +#: is either another entity (``objectId``) or a literal (``objectLiteral``). +#: +#: Bi-temporal, on two distinct axes: ``validFrom``/``validTo`` is when the fact +#: was TRUE, while ``expiredAt`` is when the graph stopped BELIEVING it because +#: a contradicting edge superseded it. +MemoryEdge = Dict[str, Any] + + +class GraphStats(TypedDict, total=False): + """Aggregate knowledge-graph counts, from ``memory.graph.stats()``. + + ``memoriesWithEdges`` against your total memory count is the useful ratio: + it says how much of what you remember made it into the graph rather than + remaining an isolated embedding. A low ratio usually means extraction is + off, or the corpus is prose the extractor found no relations in — check + ``extraction`` before concluding the latter. + """ + + entityCount: int + edgeCount: int + #: Distinct memories that produced at least one edge. + memoriesWithEdges: int + retiredEntities: int + retiredEdges: int + #: Live entity counts keyed by entity type. + entitiesByType: Dict[str, int] + #: Whether KG extraction is on, platform-wide and for this project. + extraction: Dict[str, bool] + + +class EntityWithEdges(TypedDict): + """An entity plus its 1-hop neighbourhood, from ``memory.graph.get_entity()``.""" + + entity: Optional[MemoryEntity] + edges: List[MemoryEdge] + + def enum_value(x: Any) -> Any: """Return ``x.value`` for enums, else ``x`` — lets callers pass either the enum or a raw string.""" diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..8c33478 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,177 @@ +"""Request-shaping tests for the knowledge-graph resource and the identity +provenance now carried by raw-text observe. No live server (respx mocks).""" + +import json + +import httpx +import pytest +import respx + +from memmesh import AsyncMemMesh, MemMesh + +BASE = "https://app.memmesh.ai" +PROJ = "proj_test" +PREFIX = f"{BASE}/api/v1/projects/{PROJ}" + + +def client() -> MemMesh: + return MemMesh(api_key="sk-test", project_id=PROJ, max_retries=0) + + +def _body(route) -> dict: + return json.loads(route.calls.last.request.content) + + +# ── graph reads ───────────────────────────────────────────────────────────── + + +@respx.mock +def test_stats_hits_graph_stats(): + respx.get(f"{PREFIX}/admin/memory/graph/stats").mock( + return_value=httpx.Response( + 200, + json={ + "entityCount": 12, + "edgeCount": 34, + "memoriesWithEdges": 7, + "retiredEntities": 0, + "retiredEdges": 1, + "entitiesByType": {"person": 3}, + "extraction": {"platformEnabled": True, "projectEnabled": False}, + }, + ) + ) + with client() as mm: + out = mm.memory.graph.stats() + assert out["entityCount"] == 12 + assert out["edgeCount"] == 34 + assert out["extraction"]["projectEnabled"] is False + + +@respx.mock +def test_list_entities_passes_filters_as_query_params(): + route = respx.get(f"{PREFIX}/admin/memory/entities").mock( + return_value=httpx.Response(200, json=[{"id": "e1", "canonicalName": "Sarah"}]) + ) + with client() as mm: + out = mm.memory.graph.list_entities(type="person", search="sar", limit=5) + assert out[0]["canonicalName"] == "Sarah" + q = route.calls.last.request.url.params + assert q["type"] == "person" + assert q["search"] == "sar" + assert q["limit"] == "5" + + +@respx.mock +def test_list_entities_omits_unset_filters(): + route = respx.get(f"{PREFIX}/admin/memory/entities").mock( + return_value=httpx.Response(200, json=[]) + ) + with client() as mm: + mm.memory.graph.list_entities() + assert str(route.calls.last.request.url.params) == "" + + +@respx.mock +def test_get_entity_returns_entity_with_edges(): + respx.get(f"{PREFIX}/admin/memory/entities/e1").mock( + return_value=httpx.Response( + 200, json={"entity": {"id": "e1"}, "edges": [{"id": "g1"}]} + ) + ) + with client() as mm: + out = mm.memory.graph.get_entity("e1") + assert out["entity"]["id"] == "e1" + assert len(out["edges"]) == 1 + + +@respx.mock +def test_list_edges_hits_graph_edges(): + route = respx.get(f"{PREFIX}/admin/memory/graph/edges").mock( + return_value=httpx.Response(200, json=[{"id": "g1", "predicate": "works_at"}]) + ) + with client() as mm: + out = mm.memory.graph.list_edges(limit=100) + assert out[0]["predicate"] == "works_at" + assert route.calls.last.request.url.params["limit"] == "100" + + +@respx.mock +def test_traverse_posts_entity_id_and_predicates(): + route = respx.post(f"{PREFIX}/admin/memory/graph/traverse").mock( + return_value=httpx.Response(200, json=[{"id": "g1"}]) + ) + with client() as mm: + mm.memory.graph.traverse("e1", hops=2, predicates=["member_of", "led_by"]) + assert _body(route) == { + "entityId": "e1", + "hops": 2, + "predicates": ["member_of", "led_by"], + } + + +@respx.mock +def test_traverse_omits_unset_options(): + route = respx.post(f"{PREFIX}/admin/memory/graph/traverse").mock( + return_value=httpx.Response(200, json=[]) + ) + with client() as mm: + mm.memory.graph.traverse("e1") + assert _body(route) == {"entityId": "e1"} + + +# ── observe provenance ────────────────────────────────────────────────────── + + +@respx.mock +def test_observe_text_forwards_identity_fields(): + route = respx.post(f"{PREFIX}/memory/observe").mock( + return_value=httpx.Response(200, json={"saved": [], "candidateCount": 0}) + ) + with client() as mm: + mm.memory.observe( + text="I just moved to Denver.", + user_id="user-123", + agent_id="agent-9", + session_id="thread-456", + ) + assert _body(route) == { + "text": "I just moved to Denver.", + "role": "user", + "userId": "user-123", + "agentId": "agent-9", + "sessionId": "thread-456", + } + + +@respx.mock +def test_observe_text_omits_identity_when_unset(): + """An older call site must produce the same body it always did — the fields + are omitted, not sent as null.""" + route = respx.post(f"{PREFIX}/memory/observe").mock( + return_value=httpx.Response(200, json={"saved": [], "candidateCount": 0}) + ) + with client() as mm: + mm.memory.observe(text="hello") + assert _body(route) == {"text": "hello", "role": "user"} + + +# ── async parity ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +@respx.mock +async def test_async_graph_stats_and_observe_identity(): + respx.get(f"{PREFIX}/admin/memory/graph/stats").mock( + return_value=httpx.Response(200, json={"entityCount": 1, "edgeCount": 2}) + ) + route = respx.post(f"{PREFIX}/memory/observe").mock( + return_value=httpx.Response(200, json={"saved": [], "candidateCount": 0}) + ) + async with AsyncMemMesh(api_key="sk-test", project_id=PROJ, max_retries=0) as mm: + stats = await mm.memory.graph.stats() + await mm.memory.observe(text="hi", user_id="u1", session_id="s1") + assert stats["entityCount"] == 1 + body = _body(route) + assert body["userId"] == "u1" + assert body["sessionId"] == "s1" From ca0077907f4b0429e81bea99870afd010d8c1768 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Wed, 19 Aug 2026 07:57:02 -0400 Subject: [PATCH 2/2] fix(graph): name the edge type for what the read routes return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_edges / traverse / get_entity().edges return the server's GraphTraversalEdge — subject and object are hydrated entity dicts, not ids, plus a hop counter. There is no subjectId on the wire. Python returns plain dicts so this never failed at runtime, but the type alias and docs described a shape the API does not produce. The Rust port did fail, which is how it was found. --- src/memmesh/__init__.py | 4 ++-- src/memmesh/resources/graph.py | 10 +++++----- src/memmesh/types.py | 21 +++++++++++++-------- tests/test_graph.py | 24 +++++++++++++++++++++--- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/memmesh/__init__.py b/src/memmesh/__init__.py index 31a8908..2b1a4fc 100644 --- a/src/memmesh/__init__.py +++ b/src/memmesh/__init__.py @@ -39,7 +39,7 @@ from .types import ( EntityWithEdges, GraphStats, - MemoryEdge, + GraphTraversalEdge, MemoryEntity, Accumulator, ActivityLevel, @@ -172,7 +172,7 @@ __all__ = [ "GraphStats", "MemoryEntity", - "MemoryEdge", + "GraphTraversalEdge", "EntityWithEdges", "__version__", "MemMesh", diff --git a/src/memmesh/resources/graph.py b/src/memmesh/resources/graph.py index bdb1cf7..3f21715 100644 --- a/src/memmesh/resources/graph.py +++ b/src/memmesh/resources/graph.py @@ -26,7 +26,7 @@ from typing import Any, List, Optional -from ..types import EntityWithEdges, GraphStats, MemoryEdge, MemoryEntity +from ..types import EntityWithEdges, GraphStats, GraphTraversalEdge, MemoryEntity def _entity_params( @@ -115,7 +115,7 @@ def list_edges( as_of: Optional[str] = None, limit: Optional[int] = None, project_id: Optional[str] = None, - ) -> List[MemoryEdge]: + ) -> List[GraphTraversalEdge]: """Every currently-valid edge. Use for rendering a whole small graph; for a large one, seed from an @@ -136,7 +136,7 @@ def traverse( predicates: Optional[List[str]] = None, as_of: Optional[str] = None, project_id: Optional[str] = None, - ) -> List[MemoryEdge]: + ) -> List[GraphTraversalEdge]: """Walk out from a seed entity (1-3 hops). This is the multi-hop path: the edges returned here connect facts no @@ -194,7 +194,7 @@ async def list_edges( as_of: Optional[str] = None, limit: Optional[int] = None, project_id: Optional[str] = None, - ) -> List[MemoryEdge]: + ) -> List[GraphTraversalEdge]: """Async mirror of :meth:`GraphResource.list_edges`.""" params: dict = {} if as_of is not None: @@ -211,7 +211,7 @@ async def traverse( predicates: Optional[List[str]] = None, as_of: Optional[str] = None, project_id: Optional[str] = None, - ) -> List[MemoryEdge]: + ) -> List[GraphTraversalEdge]: """Async mirror of :meth:`GraphResource.traverse`.""" return await self._t.post( "/admin/memory/graph/traverse", diff --git a/src/memmesh/types.py b/src/memmesh/types.py index 89943f4..a4dc768 100644 --- a/src/memmesh/types.py +++ b/src/memmesh/types.py @@ -134,14 +134,19 @@ class ExplainResult(TypedDict): #: that extraction filed under a ``canonicalName``, with aliases resolving to it. MemoryEntity = Dict[str, Any] -#: A typed relationship between two entities, e.g. -#: ``{"subjectId": ..., "predicate": "works_at", "objectId": ...}``. The object -#: is either another entity (``objectId``) or a literal (``objectLiteral``). +#: An edge as the READ routes return it — hydrated, not the raw ``memory_edge`` +#: row. ``subject`` and ``object`` are resolved entity dicts rather than ids, +#: plus a ``hop`` counter:: #: -#: Bi-temporal, on two distinct axes: ``validFrom``/``validTo`` is when the fact -#: was TRUE, while ``expiredAt`` is when the graph stopped BELIEVING it because -#: a contradicting edge superseded it. -MemoryEdge = Dict[str, Any] +#: {"id": ..., "subject": {...}, "predicate": "reported_metric", +#: "object": {...} | None, "objectLiteral": "NVDA" | None, +#: "weight": 0.85, "hop": 0} +#: +#: This is the server's ``GraphTraversalEdge``, returned by ``list_edges``, +#: ``traverse``, and the ``edges`` of ``get_entity``. ``hop`` is 0 from +#: ``list_edges`` (no seed) and 1-indexed from ``traverse``. The raw row shape +#: (``subjectId`` / ``objectId``) is not exposed by any read route. +GraphTraversalEdge = Dict[str, Any] class GraphStats(TypedDict, total=False): @@ -170,7 +175,7 @@ class EntityWithEdges(TypedDict): """An entity plus its 1-hop neighbourhood, from ``memory.graph.get_entity()``.""" entity: Optional[MemoryEntity] - edges: List[MemoryEdge] + edges: List[GraphTraversalEdge] def enum_value(x: Any) -> Any: diff --git a/tests/test_graph.py b/tests/test_graph.py index 8c33478..809ad09 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -86,13 +86,31 @@ def test_get_entity_returns_entity_with_edges(): @respx.mock -def test_list_edges_hits_graph_edges(): +def test_list_edges_returns_hydrated_traversal_shape(): + """The read routes hydrate both ends — `subject`/`object` are entity dicts, + not ids, and there is no `subjectId` on the wire at all.""" route = respx.get(f"{PREFIX}/admin/memory/graph/edges").mock( - return_value=httpx.Response(200, json=[{"id": "g1", "predicate": "works_at"}]) + return_value=httpx.Response( + 200, + json=[ + { + "id": "g1", + "subject": {"id": "e1", "canonicalName": "NVIDIA CORP"}, + "predicate": "reported_metric", + "object": {"id": "e2", "canonicalName": "Cost of Revenue"}, + "objectLiteral": None, + "weight": 0.85, + "hop": 0, + } + ], + ) ) with client() as mm: out = mm.memory.graph.list_edges(limit=100) - assert out[0]["predicate"] == "works_at" + assert out[0]["subject"]["canonicalName"] == "NVIDIA CORP" + assert out[0]["object"]["canonicalName"] == "Cost of Revenue" + assert out[0]["hop"] == 0 + assert "subjectId" not in out[0] assert route.calls.last.request.url.params["limit"] == "100"