Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 11ea976

Browse files
chore: promote benchmark/longmemeval-python-20260529-fda8 to production (#203)
2 parents ad183bc + ce8be33 commit 11ea976

12 files changed

Lines changed: 1535 additions & 5 deletions

File tree

.github/workflows/deploy-staging.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,15 @@ jobs:
146146
set -euo pipefail
147147
cd "${{ secrets.STAGING_EC2_DEPLOY_PATH }}"
148148
149-
PR_BRANCH="${{ github.head_ref || github.event.inputs.ref || 'develop' }}"
150-
echo "── Deploying branch: $PR_BRANCH ──"
149+
DEPLOY_REF="${{ github.head_ref || github.event.inputs.ref || 'develop' }}"
150+
echo "── Deploying ref: $DEPLOY_REF ──"
151151
152-
git fetch origin "$PR_BRANCH"
153-
git checkout "$PR_BRANCH"
154-
git pull origin "$PR_BRANCH"
152+
git fetch origin "$DEPLOY_REF"
153+
if git show-ref --verify --quiet "refs/remotes/origin/$DEPLOY_REF"; then
154+
git checkout -B "$DEPLOY_REF" "origin/$DEPLOY_REF"
155+
else
156+
git checkout --detach FETCH_HEAD
157+
fi
155158
156159
echo "── Restarting XMem staging service ──"
157160
sudo systemctl restart xmem-staging

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,16 @@ tests/
5656
!tests/
5757
!tests/**/*.py
5858
benchmarks/
59+
!benchmarks/
60+
!benchmarks/README.md
5961
LongMemEval/
62+
!benchmarks/longmemeval/
63+
!benchmarks/longmemeval/**
64+
benchmarks/longmemeval/**/__pycache__/
65+
benchmarks/longmemeval/**/*.pyc
66+
benchmarks/longmemeval/data/
67+
benchmarks/longmemeval/results/
68+
benchmarks/longmemeval/outputs/
6069
backboard/
6170
rust/
6271

benchmarks/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# XMem Benchmarks
2+
3+
This directory contains benchmark harnesses for XMem.
4+
5+
- `longmemeval/`: Python-only LongMemEval benchmark runner targeting the XMem HTTP API.
6+
7+
Benchmark runs can create large dataset and result artifacts. Keep those files under
8+
`benchmarks/longmemeval/data`, `benchmarks/longmemeval/results`, or
9+
`benchmarks/longmemeval/outputs`; those paths are intentionally ignored by git.

benchmarks/longmemeval/README.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# LongMemEval Benchmark for XMem Python
2+
3+
This harness benchmarks the Python XMem service only. It targets the deployed
4+
Python API at `https://api.xmem.in` by default and does not run or compare the
5+
Go implementation.
6+
7+
LongMemEval evaluates long-term conversational memory across multi-session
8+
recall, temporal reasoning, single-session recall, knowledge updates, and
9+
preference tracking. The harness follows the same broad structure used by
10+
open-source memory-layer benchmarks: load dataset records, ingest the haystack
11+
conversation history into an isolated user namespace, retrieve an answer for
12+
the benchmark question, write predictions, and compute lightweight local
13+
metrics for quick iteration.
14+
15+
## Files
16+
17+
- `dataset.py`: Loads JSON/JSONL LongMemEval records and converts sessions to
18+
XMem conversation-turn ingest payloads.
19+
- `client.py`: Async HTTP client for the Python XMem API.
20+
- `runner.py`: Benchmark orchestration, batching, polling, resume support, and
21+
output writing.
22+
- `metrics.py`: Local exact-match, contains, and token-F1 metrics plus summary
23+
aggregation.
24+
- `run.py`: CLI entrypoint.
25+
26+
## Secrets
27+
28+
Do not commit API keys or provider credentials.
29+
30+
To generate XMem predictions, set an XMem API key:
31+
32+
```bash
33+
export XMEM_API_KEY="..."
34+
```
35+
36+
Use `--api-key-env` if your local environment uses a different variable name.
37+
38+
To score predictions with the official LongMemEval LLM-as-judge evaluator, set
39+
an OpenAI API key before running the evaluator:
40+
41+
```bash
42+
export OPENAI_API_KEY="..."
43+
```
44+
45+
## Run a Smoke Check
46+
47+
Validate dataset parsing and payload construction without calling the service:
48+
49+
```bash
50+
python -m benchmarks.longmemeval.run \
51+
--download \
52+
--dry-run \
53+
--limit 2
54+
```
55+
56+
Validate all six official categories without requiring an API key:
57+
58+
```bash
59+
python -m benchmarks.longmemeval.run_all_categories \
60+
--download \
61+
--dry-run
62+
```
63+
64+
If the dataset is already available locally:
65+
66+
```bash
67+
python -m benchmarks.longmemeval.run \
68+
--dataset-path benchmarks/longmemeval/data/longmemeval_s_cleaned.json \
69+
--dry-run \
70+
--limit 2
71+
```
72+
73+
## Run Against the Python API
74+
75+
```bash
76+
export XMEM_API_KEY="..."
77+
78+
python -m benchmarks.longmemeval.run \
79+
--download \
80+
--api-base-url https://api.xmem.in \
81+
--limit 10 \
82+
--batch-size 25 \
83+
--output-dir benchmarks/longmemeval/results/run-001
84+
```
85+
86+
The runner writes:
87+
88+
- `results.jsonl`: Full per-example benchmark records.
89+
- `predictions.jsonl`: Official prediction file with only `question_id` and
90+
`hypothesis`.
91+
- `summary.json`: Aggregate local metrics and latency.
92+
93+
The local metrics are intended for fast development feedback. For publication
94+
quality reporting, run the generated `predictions.jsonl` through the official
95+
LongMemEval evaluation flow or an agreed LLM-as-judge rubric using the same
96+
model/settings across systems.
97+
98+
The benchmark runner itself only needs `XMEM_API_KEY` because it generates XMem
99+
answers. The official/equivalent evaluator is a separate scoring step and needs
100+
`OPENAI_API_KEY` when using an OpenAI judge model.
101+
102+
## Run All Official Categories
103+
104+
The dataset has six `question_type` categories. Each example has a unique
105+
`question_id` and its own haystack sessions, and this runner isolates each
106+
question into a separate XMem user namespace. That makes category-level
107+
parallelism safe from memory leakage; the only practical constraint is API
108+
throughput and rate limiting.
109+
110+
```bash
111+
export XMEM_API_KEY="..."
112+
113+
python -m benchmarks.longmemeval.run_all_categories \
114+
--dataset-path benchmarks/longmemeval/data/longmemeval_s_cleaned.json \
115+
--api-base-url https://api.xmem.in \
116+
--output-root benchmarks/longmemeval/results/full-six-categories \
117+
--max-parallel-categories 6
118+
```
119+
120+
The all-category runner prints live processed/left/ETA status and writes one
121+
official merged prediction file at:
122+
123+
```text
124+
benchmarks/longmemeval/results/full-six-categories/predictions.jsonl
125+
```
126+
127+
Each category also gets a `runner.log` file under its output directory. If a
128+
category process fails, the launcher prints the failing category, exit code, log
129+
path, and the most recent child-process output.
130+
131+
## Useful Options
132+
133+
- `--limit N`: Run a small subset first.
134+
- `--offset N`: Skip the first N selected examples.
135+
- `--question-type TYPE`: Filter to one LongMemEval category.
136+
- `--skip-ingest`: Reuse already-ingested user namespaces and only retrieve.
137+
- `--no-resume`: Re-run examples even if they already exist in `results.jsonl`.
138+
- `--ingest-api-version v1`: Use synchronous batch ingestion instead of the
139+
default durable `/v2/memory/batch-ingest` path.
140+
- `--effort-level high`: Use high-effort XMem ingestion for long records.
141+
- `--dry-run`: Validate dataset/category setup without API calls.
142+
- `--verbose`: Print child runner output while the all-category launcher runs.
143+
144+
## Expected Failures
145+
146+
These errors are intentional and should be actionable:
147+
148+
- `Dataset file not found`: run with `--download`, or pass `--dataset-path`.
149+
- `Missing API key`: set `XMEM_API_KEY`, or pass `--api-key-env` for a custom
150+
variable name.
151+
- Official evaluator authentication errors: set `OPENAI_API_KEY` before running
152+
the LongMemEval scoring step.
153+
- `Failed to download the LongMemEval dataset`: check network access, then retry
154+
or download the dataset manually.
155+
- `<category> failed with exit code ...`: inspect that category's `runner.log`.
156+
157+
## Isolation Model
158+
159+
Each example is ingested into a user id derived from:
160+
161+
```text
162+
<user-prefix>-<question-id>
163+
```
164+
165+
This prevents facts from one benchmark question from leaking into another. Use a
166+
new `--user-prefix` for fully fresh runs.

benchmarks/longmemeval/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""LongMemEval benchmark harness for the Python XMem API."""

benchmarks/longmemeval/client.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""HTTP client for the Python XMem API used by the benchmark."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import time
7+
from dataclasses import dataclass
8+
from typing import Any
9+
10+
import httpx
11+
12+
13+
TERMINAL_JOB_STATUSES = {"succeeded", "dead_letter"}
14+
15+
16+
@dataclass(frozen=True)
17+
class ApiCallResult:
18+
data: dict[str, Any]
19+
elapsed_ms: float
20+
21+
22+
class XMemApiClient:
23+
"""Small async client around the deployed Python XMem API."""
24+
25+
def __init__(
26+
self,
27+
*,
28+
base_url: str,
29+
api_key: str,
30+
timeout_seconds: float = 120.0,
31+
max_retries: int = 3,
32+
retry_backoff_seconds: float = 2.0,
33+
) -> None:
34+
self.base_url = base_url.rstrip("/")
35+
self.max_retries = max_retries
36+
self.retry_backoff_seconds = retry_backoff_seconds
37+
self._client = httpx.AsyncClient(
38+
base_url=self.base_url,
39+
timeout=httpx.Timeout(timeout_seconds),
40+
headers={
41+
"Authorization": f"Bearer {api_key}",
42+
"Content-Type": "application/json",
43+
"User-Agent": "xmem-longmemeval-benchmark/1.0",
44+
},
45+
)
46+
47+
async def __aenter__(self) -> "XMemApiClient":
48+
return self
49+
50+
async def __aexit__(self, exc_type, exc, tb) -> None:
51+
await self.close()
52+
53+
async def close(self) -> None:
54+
await self._client.aclose()
55+
56+
async def ingest(self, payload: dict[str, Any]) -> ApiCallResult:
57+
return await self._post("/v1/memory/ingest", payload)
58+
59+
async def batch_ingest_v1(self, items: list[dict[str, Any]]) -> ApiCallResult:
60+
return await self._post("/v1/memory/batch-ingest", {"items": items})
61+
62+
async def batch_ingest_v2(self, items: list[dict[str, Any]]) -> ApiCallResult:
63+
return await self._post("/v2/memory/batch-ingest", {"items": items})
64+
65+
async def retrieve(self, payload: dict[str, Any]) -> ApiCallResult:
66+
return await self._post("/v1/memory/retrieve", payload)
67+
68+
async def job_status(self, status_url: str) -> ApiCallResult:
69+
return await self._get(status_url)
70+
71+
async def poll_job(
72+
self,
73+
status_url: str,
74+
*,
75+
interval_seconds: float,
76+
timeout_seconds: float,
77+
) -> ApiCallResult:
78+
deadline = time.monotonic() + timeout_seconds
79+
last_result: ApiCallResult | None = None
80+
while time.monotonic() < deadline:
81+
last_result = await self.job_status(status_url)
82+
status = str(last_result.data.get("status") or "").lower()
83+
if status in TERMINAL_JOB_STATUSES:
84+
return last_result
85+
await asyncio.sleep(interval_seconds)
86+
status = last_result.data.get("status") if last_result else "unknown"
87+
raise TimeoutError(f"Timed out polling job {status_url}; last status={status}")
88+
89+
async def _get(self, path: str) -> ApiCallResult:
90+
return await self._request("GET", path)
91+
92+
async def _post(self, path: str, payload: dict[str, Any]) -> ApiCallResult:
93+
return await self._request("POST", path, json=payload)
94+
95+
async def _request(self, method: str, path: str, **kwargs: Any) -> ApiCallResult:
96+
request_path = self._request_path(path)
97+
start = time.perf_counter()
98+
response: httpx.Response | None = None
99+
for attempt in range(self.max_retries + 1):
100+
try:
101+
response = await self._client.request(method, request_path, **kwargs)
102+
if response.status_code < 500 and response.status_code != 429:
103+
break
104+
except httpx.HTTPError:
105+
if attempt >= self.max_retries:
106+
raise
107+
if attempt < self.max_retries:
108+
await asyncio.sleep(self.retry_backoff_seconds * (attempt + 1))
109+
110+
if response is None:
111+
raise RuntimeError(f"No response from {method} {request_path}")
112+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
113+
response.raise_for_status()
114+
body = response.json()
115+
if body.get("status") == "error":
116+
error = body.get("error") or f"XMem API error from {request_path}"
117+
raise RuntimeError(error)
118+
data = body.get("data")
119+
if data is None:
120+
data = {}
121+
if not isinstance(data, dict):
122+
data = {"value": data}
123+
return ApiCallResult(data=data, elapsed_ms=elapsed_ms)
124+
125+
@staticmethod
126+
def _request_path(path: str) -> str:
127+
if path.startswith(("http://", "https://", "/")):
128+
return path
129+
return f"/{path}"

0 commit comments

Comments
 (0)