graph: get_nodeset_subgraph AND filter, operator validation and edge orientation diverge across graph backends
summary
get_nodeset_subgraph(node_type, node_name, node_name_filter_operator) returns different subgraphs depending on which graph backend is configured. three independent divergences, all reproduced live on embedded backends (no services, no LLM):
- the AND filter counts different things per backend: turso counts requested names, ladybug/neo4j/postgres_demo count matched nodes
- an invalid operator string (typo like
"and") silently means AND on ladybug/turso/neo4j, raisesValueErroron postgres_demo - ladybug returns every undirected edge twice (both orientations), the others return it once
environment
- dev @ bb76f1c2 (v1.5.4), python 3.12.13, macOS arm64
- defaults: LadybugAdapter on an embedded file db, TursoAdapter on a local
sqlite+aiosqlitefile - no LLM, no embeddings, no services
finding 1: AND denominator divergence
| backend | counts | code |
|---|---|---|
| turso | HAVING COUNT(DISTINCT primary_id) = :primary_count with primary_count = len(node_name) (requested) |
cognee/infrastructure/databases/graph/turso/adapter.py:632 |
| ladybug | matched_count = $primary_count with primary_count = len(primary_ids) (matched) |
cognee/infrastructure/databases/graph/ladybug/adapter.py:3122 |
| neo4j | matched_count = size(primary) (matched) |
cognee/infrastructure/databases/graph/neo4j_driver/adapter.py:2087 |
| postgres_demo | connections >= primary_ids (matched set) |
cognee/infrastructure/databases/graph/postgres_demo/adapter.py:166 |
live result, graph = alice-bob, alice-carol, bob-dave, carol-dave (KNOWS), query get_nodeset_subgraph(Person, ["alice", "ghost"], "AND") where ghost does not exist:
| backend | nodes | edges |
|---|---|---|
| ladybug | alice, bob, carol | 4 |
| turso | alice | 0 |
same call with a duplicated name that does exist, ["alice", "alice"]:
| backend | nodes | edges |
|---|---|---|
| ladybug | alice, bob, carol | 4 |
| turso | alice | 0 |
so on turso one stale or duplicated name makes AND unsatisfiable and returns a node with no edges, while the other three backends silently degrade the AND to the matched subset and return the full neighborhood. callers doing retrieval over a nodeset cannot get consistent results across backends.
finding 2: operator validation divergence
postgres_demo/adapter.py:153 raises ValueError("node_name_filter_operator must be 'OR' or 'AND'") for anything else. ladybug (== "OR" ... else) and turso (!= "OR") treat any non-"OR" string as AND. neo4j follows the same == "OR" ... else shape.
live, get_nodeset_subgraph(Person, ["alice"], "and") (lowercase typo): ladybug and turso both return the AND neighborhood without complaint, postgres_demo raises ValueError. a typo silently changes semantics on three of four backends.
finding 3: ladybug returns both orientations of each edge
the ladybug edge query unwinds an undirected pattern (a:Node)-[r:EDGE]-(b:Node) over both endpoints (ladybug/adapter.py:3154), and the python-side filter keeps both (u,v) and (v,u).
live, get_nodeset_subgraph(Person, ["alice", "bob"], "AND"):
- ladybug:
[(alice, bob, KNOWS), (bob, alice, KNOWS)] - turso:
[(alice, bob, KNOWS)]
consumers that count edges or aggregate over them double-count on ladybug. result was stable across repeated runs.
repro
import asyncio, os, tempfile
from uuid import UUID
from pydantic import Field
from cognee.infrastructure.databases.graph.ladybug.adapter import LadybugAdapter
from cognee.infrastructure.databases.graph.turso.adapter import TursoAdapter
from cognee.infrastructure.engine import DataPoint
class Person(DataPoint):
name: str
type: str = "Person"
id: UUID = Field()
def make_nodes():
ids = {"alice": UUID(int=1), "bob": UUID(int=2), "carol": UUID(int=3), "dave": UUID(int=4)}
return [Person(id=ids[n], name=n) for n in ids]
NODE_IDS = {n.name: str(n.id) for n in make_nodes()}
EDGES = [(NODE_IDS["alice"], NODE_IDS["bob"]), (NODE_IDS["alice"], NODE_IDS["carol"]),
(NODE_IDS["bob"], NODE_IDS["dave"]), (NODE_IDS["carol"], NODE_IDS["dave"])]
async def build_ladybug(path):
a = LadybugAdapter(path)
await a.add_nodes(make_nodes())
for s, t in EDGES:
await a.add_edge(s, t, "KNOWS", {"weight": 1.0})
return a
async def build_turso(uri):
a = TursoAdapter(uri)
await a.initialize()
await a.add_nodes(make_nodes())
for s, t in EDGES:
await a.add_edge(s, t, "KNOWS", {"weight": 1.0})
return a
async def main():
tmp = tempfile.mkdtemp(prefix="cognee_probe_")
lady = await build_ladybug(os.path.join(tmp, "ladybug_db"))
turs = await build_turso(f"sqlite+aiosqlite:///{tmp}/turso.db")
for label, names, op in [
("missing-name AND", ["alice", "ghost"], "AND"),
("duplicate-name AND", ["alice", "alice"], "AND"),
("sanity AND", ["alice", "bob"], "AND"),
("typo operator", ["alice"], "and"),
]:
print(f"== {label} ==")
for tag, adapter in [("ladybug", lady), ("turso", turs)]:
try:
nodes, edges = await adapter.get_nodeset_subgraph(Person, names, op)
names_out = sorted(n[1].get("name", "?") for n in nodes)
print(f" {tag}: nodes={names_out} edges={len(edges)}")
except Exception as e:
print(f" {tag}: RAISED {type(e).__name__}: {e}")
await lady.close(); await turs.close()
asyncio.run(main())impact
get_nodeset_subgraph feeds subgraph-shaped retrieval contexts. with the same data and the same call, the returned context depends on graph_database_provider: turso silently drops the neighborhood when a name is stale or duplicated, ladybug doubles every edge, and a typo'd operator changes semantics on three backends while erroring on the fourth.
open questions for maintainers
- should the AND denominator be requested names (then ladybug/neo4j/postgres_demo need to change) or matched nodes (then turso needs to change)?
- should unknown operators raise everywhere (postgres_demo behavior) or be normalized?
- should the returned edge list be orientation-deduplicated on ladybug?
happy to work on any of the three once the intended contract is settled.
Source: topoteretes/cognee