From 22f803c0e63e200aa29e638d50f7210bfe16ab4e Mon Sep 17 00:00:00 2001 From: Robert Sigmundsson <230784065+RobertSigmundsson@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:03:25 +0200 Subject: [PATCH] fix(storage): bind datetimes in time comparisons so they select by value Two queries bound a datetime bound as an ISO string against a datetime column. SurrealDB resolves a cross-type comparison by type rank rather than by value, so the predicate became constant - and which constant depends on the operator: datetime >= string is always true, datetime <= string always false. find_neurons(time_range=...) combines both, so it matched nothing at all, for any window. get_enhanced_stats uses the one-sided >=, so today_fibers_count reported every fiber the brain had ever held as created today - a fabricated number surfaced by smem info and the dashboard. InMemoryStorage already implemented both comparisons on Python objects, so this restores parity between the backends rather than changing the contract. Covered by live regression tests; the schemaless in-memory backend compares Python objects and cannot reproduce either fault. --- src/surreal_memory/storage/surrealdb/store.py | 14 +- tests/unit/_surrealdb_live.py | 1 + .../test_surrealdb_time_range_filter_live.py | 138 ++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_surrealdb_time_range_filter_live.py diff --git a/src/surreal_memory/storage/surrealdb/store.py b/src/surreal_memory/storage/surrealdb/store.py index 6fb5a026..c91ef54f 100755 --- a/src/surreal_memory/storage/surrealdb/store.py +++ b/src/surreal_memory/storage/surrealdb/store.py @@ -1278,9 +1278,14 @@ async def find_neurons( if time_range is not None: start, end = time_range + # Bind datetimes, not ISO strings: created_at is a datetime column, and + # SurrealDB resolves a cross-type comparison by type rank rather than by + # value, so a string bound here makes the predicate CONSTANT - and which + # constant depends on the operator: `datetime >= string` is always true, + # `datetime <= string` always false. Here the two combine to match nothing. conditions.append("created_at >= $time_start AND created_at <= $time_end") - params["time_start"] = start.isoformat() - params["time_end"] = end.isoformat() + params["time_start"] = start + params["time_end"] = end if ephemeral is not None: conditions.append("ephemeral = $ephemeral") @@ -2726,7 +2731,10 @@ async def get_enhanced_stats( "SELECT count() AS c FROM fiber " "WHERE brain_id = $bid AND created_at >= $today GROUP ALL", bid=brain_id, - today=today.isoformat(), + # Bind the datetime, not an ISO string - see find_neurons above. + # Here the comparison is one-sided (>=), where a string bound makes + # the predicate constantly TRUE, so the count silently became "all". + today=today, ), self._query( "SELECT * FROM neuron_state WHERE brain_id = $bid " diff --git a/tests/unit/_surrealdb_live.py b/tests/unit/_surrealdb_live.py index 1d2d4d2c..c932ab3b 100644 --- a/tests/unit/_surrealdb_live.py +++ b/tests/unit/_surrealdb_live.py @@ -44,6 +44,7 @@ "parity-test-surreal", # test_get_project_memories.py "snapshot-roundtrip-live", # test_surrealdb_export_import_live.py "snapshot-roundtrip-live-target", # test_surrealdb_export_import_live.py + "time-range-filter-live", # test_surrealdb_time_range_filter_live.py "pinned-expiry-test-9f3a1c", # test_surrealdb_expiry_respects_pinned_live.py "bug006-tm-delete-id-live", # test_surrealdb_typed_memory_delete_id_live.py "kw-df-batch-live-4b8d2e", # test_surrealdb_keyword_df_live.py diff --git a/tests/unit/test_surrealdb_time_range_filter_live.py b/tests/unit/test_surrealdb_time_range_filter_live.py new file mode 100644 index 00000000..0e8607a9 --- /dev/null +++ b/tests/unit/test_surrealdb_time_range_filter_live.py @@ -0,0 +1,138 @@ +"""Regression: find_neurons(time_range=...) must select by value, not by type rank. + +`created_at` is a datetime column, but the query bound `$time_start`/`$time_end` as +ISO strings. SurrealDB resolves a comparison between two different types by type +rank rather than by value, so `created_at >= $time_start AND created_at <= $time_end` +became a constant predicate and the filter matched nothing at all - including rows +squarely inside the window. + +The in-memory backend compares Python objects and so cannot reproduce this; only a +live engine can. The test is skipped when SURREALDB_URL is unset so CI without +docker still passes. +""" + +from __future__ import annotations + +import dataclasses +import os +from datetime import timedelta + +import pytest + +from surreal_memory.core.brain import Brain +from surreal_memory.core.fiber import Fiber +from surreal_memory.core.neuron import Neuron, NeuronType +from surreal_memory.utils.timeutils import utcnow +from tests.unit._surrealdb_live import cleanup_live_brains, ensure_real_surrealdb_sdk + +SURREALDB_URL = os.getenv("SURREALDB_URL") + +pytestmark = pytest.mark.skipif( + not SURREALDB_URL, + reason="requires SURREALDB_URL env var pointing to a running SurrealDB", +) + + +@pytest.fixture +async def surrealdb_storage(): # type: ignore[no-untyped-def] + ensure_real_surrealdb_sdk() + from surreal_memory.storage.surrealdb.store import SurrealDBStorage + + storage = SurrealDBStorage(url=SURREALDB_URL) + await storage.initialize() + brain = Brain.create(name="time-range-filter-live") + await storage.save_brain(brain) + storage.set_brain(brain.id) + yield storage + try: + await cleanup_live_brains(storage, own_brain_id=brain.id) + except Exception: + pass + try: + await storage.close() + except Exception: + pass + + +@pytest.mark.asyncio +async def test_time_range_returns_rows_inside_the_window(surrealdb_storage) -> None: # type: ignore[no-untyped-def] + """A window around now returns the neurons created now.""" + now = utcnow() + for i in range(3): + await surrealdb_storage.add_neuron( + Neuron.create(type=NeuronType.CONCEPT, content=f"time-range probe {i}") + ) + + found = await surrealdb_storage.find_neurons( + time_range=(now - timedelta(hours=1), now + timedelta(hours=1)), limit=100 + ) + assert len(found) == 3, "a window containing the rows returned nothing" + + +@pytest.mark.asyncio +async def test_time_range_excludes_rows_outside_the_window(surrealdb_storage) -> None: # type: ignore[no-untyped-def] + """Windows in the far past and far future match nothing - the filter narrows.""" + now = utcnow() + await surrealdb_storage.add_neuron( + Neuron.create(type=NeuronType.CONCEPT, content="time-range probe outside") + ) + + future_start = now + timedelta(days=1) + future = await surrealdb_storage.find_neurons( + time_range=(future_start, future_start + timedelta(days=1)), limit=100 + ) + assert future == [] + + past_start = now - timedelta(days=2) + past = await surrealdb_storage.find_neurons( + time_range=(past_start, past_start + timedelta(days=1)), limit=100 + ) + assert past == [] + + +@pytest.mark.asyncio +async def test_time_neuron_lookup_can_reach_storage(surrealdb_storage) -> None: # type: ignore[no-untyped-def] + """The pipeline's TIME lookup can see a neuron through this filter at all. + + This asserts reachability, not de-duplication: `_find_similar_time_neuron` + compares `created_at` (insert time) against a window around the hint's + midpoint (referenced time), which are different quantities, so it only + coincides when a neuron was inserted near the referenced moment. While the + filter matched nothing, the lookup could never return anything at all. + """ + from surreal_memory.engine.pipeline_steps import _find_similar_time_neuron + + now = utcnow() + await surrealdb_storage.add_neuron( + Neuron.create(type=NeuronType.TIME, content="this afternoon") + ) + + existing = await _find_similar_time_neuron(surrealdb_storage, now) + assert existing is not None, "the TIME lookup could not see a neuron inserted just now" + + +@pytest.mark.asyncio +async def test_today_fibers_count_counts_only_today(surrealdb_storage) -> None: # type: ignore[no-untyped-def] + """`today_fibers_count` must not count fibers created before today. + + The same string-bound comparison appeared in get_enhanced_stats, but with a + one-sided `>=`, where the constant predicate is always TRUE - so the counter + reported every fiber the brain had ever held as created today. It surfaces in + `smem info` and on the dashboard. + """ + neuron = Neuron.create(type=NeuronType.CONCEPT, content="fiber anchor") + await surrealdb_storage.add_neuron(neuron) + + fresh = Fiber.create( + neuron_ids={neuron.id}, synapse_ids=set(), anchor_neuron_id=neuron.id, summary="today" + ) + await surrealdb_storage.add_fiber(fresh) + + old = Fiber.create( + neuron_ids={neuron.id}, synapse_ids=set(), anchor_neuron_id=neuron.id, summary="old" + ) + old = dataclasses.replace(old, created_at=utcnow() - timedelta(days=10)) + await surrealdb_storage.add_fiber(old) + + stats = await surrealdb_storage.get_enhanced_stats(surrealdb_storage.current_brain_id) + assert stats["today_fibers_count"] == 1, "the counter included a fiber created ten days ago"