@pentatonic-ai/ai-agent-sdk 0.7.13 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/memory/openclaw-plugin/index.js +7 -0
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/engine.test.js +142 -0
- package/packages/memory/src/engine.js +65 -0
- package/packages/memory-engine/compat/server.py +90 -5
- package/packages/memory-engine/docker-compose.yml +18 -8
- package/packages/memory-engine/engine/services/_shared/__init__.py +1 -0
- package/packages/memory-engine/engine/services/_shared/embed_provider.py +431 -0
- package/packages/memory-engine/engine/services/l2/Dockerfile +4 -2
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +640 -81
- package/packages/memory-engine/engine/services/l4/Dockerfile +5 -1
- package/packages/memory-engine/engine/services/l4/server.py +19 -57
- package/packages/memory-engine/engine/services/l5/Dockerfile +3 -1
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +24 -32
- package/packages/memory-engine/engine/services/l6/Dockerfile +3 -1
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +24 -29
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
- package/packages/memory-engine/tests/e2e_arena.sh +28 -4
- package/packages/memory-engine/tests/test_aggregate.py +333 -0
- package/packages/memory-engine/tests/test_arena_safety.py +232 -0
- package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
- package/packages/memory-engine/tests/test_embed_provider.py +354 -0
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Integration tests for the /aggregate-internal reader fast path.
|
|
2
|
+
|
|
3
|
+
Sister file to ``test_channel_stat_rollups.py``: where that file
|
|
4
|
+
covers the *writer* Cypher (ChannelStat nodes are maintained on every
|
|
5
|
+
store), this file covers the *reader* — proves that
|
|
6
|
+
``aggregate_internal`` actually reads from the denormalisation when
|
|
7
|
+
the conditions are right, falls through to the edge walk when they
|
|
8
|
+
aren't, and produces the same response shape either way.
|
|
9
|
+
|
|
10
|
+
The PR review for #33 flagged that the writer was thoroughly tested
|
|
11
|
+
but the reader's fast path only had indirect coverage via the
|
|
12
|
+
endpoint integration test in ``test_aggregate.py``. This file closes
|
|
13
|
+
that gap by importing the proxy module and calling ``aggregate_internal``
|
|
14
|
+
directly, so a regression in the fast-path branch can't be masked by
|
|
15
|
+
the silent fall-through to the edge walk.
|
|
16
|
+
|
|
17
|
+
Gated on NEO4J_TEST_URI + NEO4J_TEST_PASSWORD; skip cleanly when
|
|
18
|
+
those env vars are absent so unit-only test runs stay fast.
|
|
19
|
+
|
|
20
|
+
Run:
|
|
21
|
+
|
|
22
|
+
cd packages/memory-engine
|
|
23
|
+
NEO4J_TEST_URI=bolt://localhost:17687 \\
|
|
24
|
+
NEO4J_TEST_PASSWORD=testpassword \\
|
|
25
|
+
.venv/bin/python -m pytest tests/test_channel_stat_reader.py -v
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import importlib.util
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import uuid
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import pytest
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_NEO4J_URI = os.environ.get("NEO4J_TEST_URI")
|
|
40
|
+
_NEO4J_USER = os.environ.get("NEO4J_TEST_USER", "neo4j")
|
|
41
|
+
_NEO4J_PASSWORD = os.environ.get("NEO4J_TEST_PASSWORD")
|
|
42
|
+
|
|
43
|
+
_skip_no_neo4j = pytest.mark.skipif(
|
|
44
|
+
not (_NEO4J_URI and _NEO4J_PASSWORD),
|
|
45
|
+
reason="set NEO4J_TEST_URI + NEO4J_TEST_PASSWORD to run integration tests",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ENGINE_ROOT = Path(__file__).resolve().parent.parent / "engine" / "services" / "l2"
|
|
50
|
+
sys.path.insert(0, str(ENGINE_ROOT))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(scope="module")
|
|
54
|
+
def proxy_module():
|
|
55
|
+
"""Load l2-hybridrag-proxy as a module and point it at the test
|
|
56
|
+
Neo4j. The module reads NEO4J_URI/NEO4J_AUTH as module-level
|
|
57
|
+
constants at import time; we override them after load so the
|
|
58
|
+
request handler dials the test instance instead of the default
|
|
59
|
+
localhost:7687.
|
|
60
|
+
|
|
61
|
+
Skip cleanly if fastapi/neo4j aren't importable (matches the
|
|
62
|
+
pattern in test_aggregate.py)."""
|
|
63
|
+
spec = importlib.util.spec_from_file_location(
|
|
64
|
+
"l2_proxy_module",
|
|
65
|
+
ENGINE_ROOT / "l2-hybridrag-proxy.py",
|
|
66
|
+
)
|
|
67
|
+
assert spec and spec.loader
|
|
68
|
+
try:
|
|
69
|
+
mod = importlib.util.module_from_spec(spec)
|
|
70
|
+
spec.loader.exec_module(mod)
|
|
71
|
+
except ImportError:
|
|
72
|
+
pytest.skip("l2 proxy deps unavailable in this venv (fine for unit-only runs)")
|
|
73
|
+
mod.NEO4J_URI = _NEO4J_URI
|
|
74
|
+
mod.NEO4J_AUTH = (_NEO4J_USER, _NEO4J_PASSWORD)
|
|
75
|
+
return mod
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def neo4j_driver():
|
|
80
|
+
"""Per-test driver + cleanup. Two arenas so isolation tests can
|
|
81
|
+
run side by side without trampling each other."""
|
|
82
|
+
from neo4j import GraphDatabase
|
|
83
|
+
|
|
84
|
+
driver = GraphDatabase.driver(_NEO4J_URI, auth=(_NEO4J_USER, _NEO4J_PASSWORD))
|
|
85
|
+
arenas = [f"rdr_a_{uuid.uuid4().hex[:8]}", f"rdr_b_{uuid.uuid4().hex[:8]}"]
|
|
86
|
+
yield driver, arenas
|
|
87
|
+
with driver.session() as session:
|
|
88
|
+
for arena in arenas:
|
|
89
|
+
session.run(
|
|
90
|
+
"MATCH (n) WHERE n.arena = $arena DETACH DELETE n",
|
|
91
|
+
arena=arena,
|
|
92
|
+
)
|
|
93
|
+
driver.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _write_with_stats(
|
|
97
|
+
session,
|
|
98
|
+
arena: str,
|
|
99
|
+
cid: str,
|
|
100
|
+
email: str,
|
|
101
|
+
channel: str,
|
|
102
|
+
direction: str,
|
|
103
|
+
occurred_at: str,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Mirror the writer Cypher in /index-internal-batch's email path,
|
|
106
|
+
including the ChannelStat rollup. Keeping this inline rather than
|
|
107
|
+
importing from test_channel_stat_rollups.py so this file can be
|
|
108
|
+
read top-to-bottom without cross-file detective work."""
|
|
109
|
+
now_iso = "2026-05-11T00:00:00Z"
|
|
110
|
+
session.run(
|
|
111
|
+
"""
|
|
112
|
+
MERGE (c:Chunk {id: $cid})
|
|
113
|
+
SET c.text = 't', c.path = 'p', c.arena = $arena,
|
|
114
|
+
c.created_at = $now
|
|
115
|
+
""",
|
|
116
|
+
cid=cid, arena=arena, now=now_iso,
|
|
117
|
+
)
|
|
118
|
+
session.run(
|
|
119
|
+
"""
|
|
120
|
+
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
121
|
+
ON CREATE SET p.created_at = $now
|
|
122
|
+
WITH p
|
|
123
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
124
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
125
|
+
ON CREATE SET r.channel = $channel,
|
|
126
|
+
r.direction = $direction,
|
|
127
|
+
r.occurred_at = $occurred_at,
|
|
128
|
+
r.weight = 1.0,
|
|
129
|
+
r._counted = false
|
|
130
|
+
WITH p, r
|
|
131
|
+
FOREACH (_ IN CASE WHEN r._counted = false THEN [1] ELSE [] END |
|
|
132
|
+
MERGE (s:ChannelStat {arena: $arena, person_email: $email, channel: $channel})
|
|
133
|
+
ON CREATE SET s.count = 0,
|
|
134
|
+
s.inbound = 0,
|
|
135
|
+
s.outbound = 0,
|
|
136
|
+
s.first_seen = $occurred_at,
|
|
137
|
+
s.last_seen = $occurred_at,
|
|
138
|
+
s.created_at = $now
|
|
139
|
+
SET s.count = s.count + 1,
|
|
140
|
+
s.inbound = s.inbound + (CASE WHEN $direction = 'inbound' THEN 1 ELSE 0 END),
|
|
141
|
+
s.outbound = s.outbound + (CASE WHEN $direction = 'outbound' THEN 1 ELSE 0 END),
|
|
142
|
+
s.first_seen = CASE
|
|
143
|
+
WHEN $occurred_at < coalesce(s.first_seen, $occurred_at)
|
|
144
|
+
THEN $occurred_at
|
|
145
|
+
ELSE s.first_seen END,
|
|
146
|
+
s.last_seen = CASE
|
|
147
|
+
WHEN $occurred_at > coalesce(s.last_seen, '')
|
|
148
|
+
THEN $occurred_at
|
|
149
|
+
ELSE s.last_seen END,
|
|
150
|
+
s.updated_at = $now
|
|
151
|
+
MERGE (p)-[:HAS_STAT]->(s)
|
|
152
|
+
SET r._counted = true
|
|
153
|
+
)
|
|
154
|
+
""",
|
|
155
|
+
arena=arena, email=email, cid=cid,
|
|
156
|
+
channel=channel, direction=direction,
|
|
157
|
+
occurred_at=occurred_at, now=now_iso,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _write_edges_only(
|
|
162
|
+
session,
|
|
163
|
+
arena: str,
|
|
164
|
+
cid: str,
|
|
165
|
+
email: str,
|
|
166
|
+
channel: str,
|
|
167
|
+
direction: str,
|
|
168
|
+
occurred_at: str,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Write Person + COMMUNICATED + Chunk *without* a ChannelStat.
|
|
171
|
+
Simulates pre-rollout data: older tenants whose ingest never went
|
|
172
|
+
through the new writer. Reader fast path must fall through to the
|
|
173
|
+
edge walk and still return correct results for these rows."""
|
|
174
|
+
now_iso = "2026-05-11T00:00:00Z"
|
|
175
|
+
session.run(
|
|
176
|
+
"""
|
|
177
|
+
MERGE (c:Chunk {id: $cid})
|
|
178
|
+
SET c.text = 't', c.path = 'p', c.arena = $arena,
|
|
179
|
+
c.created_at = $now
|
|
180
|
+
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
181
|
+
ON CREATE SET p.created_at = $now
|
|
182
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
183
|
+
ON CREATE SET r.channel = $channel,
|
|
184
|
+
r.direction = $direction,
|
|
185
|
+
r.occurred_at = $occurred_at,
|
|
186
|
+
r.weight = 1.0
|
|
187
|
+
""",
|
|
188
|
+
cid=cid, arena=arena, email=email,
|
|
189
|
+
channel=channel, direction=direction,
|
|
190
|
+
occurred_at=occurred_at, now=now_iso,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ensure_indexes(session) -> None:
|
|
195
|
+
"""Run the same index/constraint statements the writer runs at the
|
|
196
|
+
top of /index-internal-batch. Tests that exercise the constraint
|
|
197
|
+
behaviour need it present; the writer fixture in
|
|
198
|
+
test_channel_stat_rollups.py doesn't fire this path."""
|
|
199
|
+
session.run(
|
|
200
|
+
"CREATE INDEX channelstat_arena_email IF NOT EXISTS "
|
|
201
|
+
"FOR (s:ChannelStat) ON (s.arena, s.person_email)"
|
|
202
|
+
)
|
|
203
|
+
session.run(
|
|
204
|
+
"CREATE CONSTRAINT channelstat_unique IF NOT EXISTS "
|
|
205
|
+
"FOR (s:ChannelStat) REQUIRE (s.arena, s.person_email, s.channel) IS UNIQUE"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _call_aggregate(proxy_module, **kwargs):
|
|
210
|
+
"""Invoke aggregate_internal as the FastAPI route would, but
|
|
211
|
+
without going through HTTP. The handler is async; this helper
|
|
212
|
+
wraps the asyncio.run boilerplate so individual tests stay terse."""
|
|
213
|
+
req = proxy_module.AggregateInternalRequest(**kwargs)
|
|
214
|
+
return asyncio.run(proxy_module.aggregate_internal(req))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# UNIQUE constraint.
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@_skip_no_neo4j
|
|
223
|
+
def test_unique_constraint_present_after_index_setup(neo4j_driver) -> None:
|
|
224
|
+
"""Pin the constraint exists. Without it, concurrent writers can
|
|
225
|
+
create rival ChannelStat nodes for the same (arena, email,
|
|
226
|
+
channel) tuple — MERGE doesn't lock without a constraint."""
|
|
227
|
+
driver, _ = neo4j_driver
|
|
228
|
+
with driver.session() as session:
|
|
229
|
+
_ensure_indexes(session)
|
|
230
|
+
names = {
|
|
231
|
+
rec["name"]
|
|
232
|
+
for rec in session.run("SHOW CONSTRAINTS YIELD name")
|
|
233
|
+
}
|
|
234
|
+
assert "channelstat_unique" in names
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@_skip_no_neo4j
|
|
238
|
+
def test_unique_constraint_rejects_duplicate_channelstat(neo4j_driver) -> None:
|
|
239
|
+
"""End-to-end: with the constraint in place, an attempt to CREATE
|
|
240
|
+
a second node for the same key fails. Belt-and-braces against the
|
|
241
|
+
MERGE pattern ever falling back to CREATE under contention."""
|
|
242
|
+
from neo4j.exceptions import ConstraintError
|
|
243
|
+
|
|
244
|
+
driver, (arena, _) = neo4j_driver
|
|
245
|
+
with driver.session() as session:
|
|
246
|
+
_ensure_indexes(session)
|
|
247
|
+
session.run(
|
|
248
|
+
"CREATE (s:ChannelStat {arena: $arena, person_email: $email, "
|
|
249
|
+
"channel: $channel, count: 1})",
|
|
250
|
+
arena=arena, email="alex@x.io", channel="email",
|
|
251
|
+
)
|
|
252
|
+
with pytest.raises(ConstraintError):
|
|
253
|
+
session.run(
|
|
254
|
+
"CREATE (s:ChannelStat {arena: $arena, person_email: $email, "
|
|
255
|
+
"channel: $channel, count: 1})",
|
|
256
|
+
arena=arena, email="alex@x.io", channel="email",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Fast-path reader behaviour.
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@_skip_no_neo4j
|
|
266
|
+
def test_fast_path_returns_per_channel_buckets_from_stats(
|
|
267
|
+
neo4j_driver, proxy_module
|
|
268
|
+
) -> None:
|
|
269
|
+
"""When ChannelStats exist for the contact, /aggregate-internal
|
|
270
|
+
should return one bucket per channel, ordered by count desc."""
|
|
271
|
+
driver, (arena, _) = neo4j_driver
|
|
272
|
+
email = "alex@x.io"
|
|
273
|
+
with driver.session() as session:
|
|
274
|
+
_ensure_indexes(session)
|
|
275
|
+
# 3 emails (2 in, 1 out), 1 slack (in).
|
|
276
|
+
_write_with_stats(session, arena, "c1", email, "email", "inbound", "2026-05-09T09:00:00Z")
|
|
277
|
+
_write_with_stats(session, arena, "c2", email, "email", "outbound", "2026-05-09T10:00:00Z")
|
|
278
|
+
_write_with_stats(session, arena, "c3", email, "email", "inbound", "2026-05-09T11:00:00Z")
|
|
279
|
+
_write_with_stats(session, arena, "c4", email, "slack", "inbound", "2026-05-09T08:00:00Z")
|
|
280
|
+
|
|
281
|
+
out = _call_aggregate(
|
|
282
|
+
proxy_module,
|
|
283
|
+
arena=arena, contact_email=email, group_by=["channel"],
|
|
284
|
+
)
|
|
285
|
+
assert out.total == 4
|
|
286
|
+
assert out.last_seen == "2026-05-09T11:00:00Z"
|
|
287
|
+
assert len(out.buckets) == 2
|
|
288
|
+
# Busiest first.
|
|
289
|
+
email_bucket = out.buckets[0]
|
|
290
|
+
slack_bucket = out.buckets[1]
|
|
291
|
+
assert email_bucket.keys == {"channel": "email"}
|
|
292
|
+
assert email_bucket.count == 3
|
|
293
|
+
assert email_bucket.inbound == 2
|
|
294
|
+
assert email_bucket.outbound == 1
|
|
295
|
+
assert slack_bucket.keys == {"channel": "slack"}
|
|
296
|
+
assert slack_bucket.count == 1
|
|
297
|
+
assert slack_bucket.inbound == 1
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@_skip_no_neo4j
|
|
301
|
+
def test_fast_path_returns_single_bucket_when_group_by_empty(
|
|
302
|
+
neo4j_driver, proxy_module
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Empty group_by collapses ChannelStat rows into one global
|
|
305
|
+
bucket — totals summed across channels, inbound/outbound likewise,
|
|
306
|
+
last_seen = max across all channels, first_seen = min."""
|
|
307
|
+
driver, (arena, _) = neo4j_driver
|
|
308
|
+
email = "alex@x.io"
|
|
309
|
+
with driver.session() as session:
|
|
310
|
+
_ensure_indexes(session)
|
|
311
|
+
_write_with_stats(session, arena, "c1", email, "email", "inbound", "2026-05-08T09:00:00Z")
|
|
312
|
+
_write_with_stats(session, arena, "c2", email, "email", "outbound", "2026-05-09T10:00:00Z")
|
|
313
|
+
_write_with_stats(session, arena, "c3", email, "slack", "inbound", "2026-05-07T15:00:00Z")
|
|
314
|
+
|
|
315
|
+
out = _call_aggregate(
|
|
316
|
+
proxy_module,
|
|
317
|
+
arena=arena, contact_email=email, group_by=[],
|
|
318
|
+
)
|
|
319
|
+
assert out.total == 3
|
|
320
|
+
assert len(out.buckets) == 1
|
|
321
|
+
bucket = out.buckets[0]
|
|
322
|
+
assert bucket.keys == {}
|
|
323
|
+
assert bucket.count == 3
|
|
324
|
+
assert bucket.inbound == 2
|
|
325
|
+
assert bucket.outbound == 1
|
|
326
|
+
# Time bounds span the full range across channels.
|
|
327
|
+
assert bucket.first_seen == "2026-05-07T15:00:00Z"
|
|
328
|
+
assert bucket.last_seen == "2026-05-09T10:00:00Z"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@_skip_no_neo4j
|
|
332
|
+
def test_fast_path_falls_through_to_edge_walk_when_stats_absent(
|
|
333
|
+
neo4j_driver, proxy_module
|
|
334
|
+
) -> None:
|
|
335
|
+
"""The forward-only optimisation: pre-rollout data has
|
|
336
|
+
COMMUNICATED edges but no ChannelStat nodes. The fast-path check
|
|
337
|
+
must fall through silently and the edge-walk path must still
|
|
338
|
+
return the correct buckets — this is the contract that means no
|
|
339
|
+
migration is needed."""
|
|
340
|
+
driver, (arena, _) = neo4j_driver
|
|
341
|
+
email = "legacy@example.com"
|
|
342
|
+
with driver.session() as session:
|
|
343
|
+
_ensure_indexes(session)
|
|
344
|
+
# Edges only — no ChannelStat written, simulating an older
|
|
345
|
+
# tenant whose data was ingested before this PR.
|
|
346
|
+
_write_edges_only(session, arena, "c1", email, "email", "inbound", "2026-05-09T09:00:00Z")
|
|
347
|
+
_write_edges_only(session, arena, "c2", email, "email", "outbound", "2026-05-09T10:00:00Z")
|
|
348
|
+
# Sanity: confirm no ChannelStat exists for this contact.
|
|
349
|
+
rows = list(session.run(
|
|
350
|
+
"MATCH (s:ChannelStat {arena: $arena, person_email: $email}) RETURN s",
|
|
351
|
+
arena=arena, email=email,
|
|
352
|
+
))
|
|
353
|
+
assert rows == []
|
|
354
|
+
|
|
355
|
+
out = _call_aggregate(
|
|
356
|
+
proxy_module,
|
|
357
|
+
arena=arena, contact_email=email, group_by=["channel"],
|
|
358
|
+
)
|
|
359
|
+
# Edge-walk path took over and returned the same shape.
|
|
360
|
+
assert out.total == 2
|
|
361
|
+
assert len(out.buckets) == 1
|
|
362
|
+
assert out.buckets[0].keys == {"channel": "email"}
|
|
363
|
+
assert out.buckets[0].inbound == 1
|
|
364
|
+
assert out.buckets[0].outbound == 1
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@_skip_no_neo4j
|
|
368
|
+
def test_fast_path_arena_isolated(neo4j_driver, proxy_module) -> None:
|
|
369
|
+
"""A's aggregate must never reflect B's stats even when both
|
|
370
|
+
arenas have the same email — the channelstat_arena_email index is
|
|
371
|
+
keyed on arena first to enforce this. Companion to the writer-
|
|
372
|
+
side isolation test in test_channel_stat_rollups.py."""
|
|
373
|
+
driver, (arena_a, arena_b) = neo4j_driver
|
|
374
|
+
email = "shared@example.com"
|
|
375
|
+
with driver.session() as session:
|
|
376
|
+
_ensure_indexes(session)
|
|
377
|
+
_write_with_stats(session, arena_a, "ca1", email, "email", "inbound", "2026-05-09T09:00:00Z")
|
|
378
|
+
_write_with_stats(session, arena_b, "cb1", email, "email", "inbound", "2026-05-09T10:00:00Z")
|
|
379
|
+
_write_with_stats(session, arena_b, "cb2", email, "slack", "outbound", "2026-05-09T11:00:00Z")
|
|
380
|
+
|
|
381
|
+
out_a = _call_aggregate(
|
|
382
|
+
proxy_module, arena=arena_a, contact_email=email, group_by=["channel"],
|
|
383
|
+
)
|
|
384
|
+
out_b = _call_aggregate(
|
|
385
|
+
proxy_module, arena=arena_b, contact_email=email, group_by=["channel"],
|
|
386
|
+
)
|
|
387
|
+
assert out_a.total == 1
|
|
388
|
+
assert len(out_a.buckets) == 1
|
|
389
|
+
assert out_a.buckets[0].keys == {"channel": "email"}
|
|
390
|
+
assert out_b.total == 2
|
|
391
|
+
assert len(out_b.buckets) == 2
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@_skip_no_neo4j
|
|
395
|
+
def test_fast_path_and_edge_walk_produce_equivalent_totals(
|
|
396
|
+
neo4j_driver, proxy_module
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Equivalence: for data that has BOTH stats and edges (the
|
|
399
|
+
normal post-rollout case), the fast-path response must match what
|
|
400
|
+
the edge walk would have returned. Caught a class of bug where
|
|
401
|
+
the writer's rollup drifts from the edges — surface it via
|
|
402
|
+
response divergence rather than waiting for users to notice
|
|
403
|
+
relationships-UI inconsistency."""
|
|
404
|
+
driver, (arena, _) = neo4j_driver
|
|
405
|
+
email = "alex@x.io"
|
|
406
|
+
with driver.session() as session:
|
|
407
|
+
_ensure_indexes(session)
|
|
408
|
+
_write_with_stats(session, arena, "c1", email, "email", "inbound", "2026-05-08T09:00:00Z")
|
|
409
|
+
_write_with_stats(session, arena, "c2", email, "email", "outbound", "2026-05-09T10:00:00Z")
|
|
410
|
+
_write_with_stats(session, arena, "c3", email, "slack", "inbound", "2026-05-07T15:00:00Z")
|
|
411
|
+
|
|
412
|
+
# Compute the edge-walk answer by hand from the COMMUNICATED
|
|
413
|
+
# rows. This is the ground truth the rollup should reflect.
|
|
414
|
+
edge_rows = list(session.run(
|
|
415
|
+
"MATCH (p:Person {arena: $arena, email: $email})-[r:COMMUNICATED]->(:Chunk {arena: $arena})\n"
|
|
416
|
+
"WITH r.channel AS channel, r.direction AS direction\n"
|
|
417
|
+
"RETURN channel,\n"
|
|
418
|
+
" count(*) AS count,\n"
|
|
419
|
+
" sum(CASE WHEN direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
|
|
420
|
+
" sum(CASE WHEN direction = 'outbound' THEN 1 ELSE 0 END) AS outbound\n"
|
|
421
|
+
"ORDER BY count DESC",
|
|
422
|
+
arena=arena, email=email,
|
|
423
|
+
))
|
|
424
|
+
ground_truth = {
|
|
425
|
+
rec["channel"]: (int(rec["count"]), int(rec["inbound"]), int(rec["outbound"]))
|
|
426
|
+
for rec in edge_rows
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
out = _call_aggregate(
|
|
430
|
+
proxy_module, arena=arena, contact_email=email, group_by=["channel"],
|
|
431
|
+
)
|
|
432
|
+
fast_path = {
|
|
433
|
+
b.keys["channel"]: (b.count, b.inbound, b.outbound)
|
|
434
|
+
for b in out.buckets
|
|
435
|
+
}
|
|
436
|
+
assert fast_path == ground_truth
|
|
437
|
+
assert out.total == sum(c for c, _, _ in ground_truth.values())
|