ReMe/benchmark/longmemeval/run.py
xyf2020 9975bb37b9
Some checks failed
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
CI / Python packages / Build and verify distributions (push) Waiting to run
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
CI / Documentation / Test and build documentation (push) Has been cancelled
Separate benchmark judge plugins (#535)
2026-09-10 20:16:46 +08:00

831 lines
33 KiB
Python

"""LongMemEval evaluation runner for ReMe.
Evaluates ReMe's long-term memory capability using the LongMemEval dataset.
Each item gets an isolated workspace; sessions are ingested in chronological order;
dream is triggered when sessions cross midnight (23:00); finally questions are
answered via an agentic (ReAct) approach and judged by an LLM.
Usage:
python benchmark/longmemeval/run.py
python benchmark/longmemeval/run.py --config benchmark/longmemeval/config.yaml
python benchmark/longmemeval/run.py -q # quiet: only eval-level logs
python benchmark/longmemeval/run.py --log-level WARNING # reduce eval runner logs
python benchmark/longmemeval/run.py --reme-log-level WARNING # reduce reme internal logs
python benchmark/longmemeval/run.py --eval_only # query+judge only, reuse existing workspace
"""
import json
import logging
import os
import re
import shutil
import time
import threading
from datetime import datetime
from pathlib import Path
import yaml
from dotenv import load_dotenv
# Load .env from project root
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
load_dotenv(_PROJECT_ROOT / ".env")
# Workspace root for evaluation items — read from config.yaml (dataset.workspace_root)
_WORKSPACE_ROOT_DEFAULT = "benchmark/longmemeval/workspaces/longmemeval-s"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
_DEFAULT_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(message)s"
logging.basicConfig(level=logging.INFO, format=_DEFAULT_LOG_FORMAT)
logger = logging.getLogger("longmemeval")
# Noisy library loggers silenced by default
_NOISY_LOGGERS = [
"httpx",
"httpcore",
"openai",
"uvicorn",
"multipart",
"asyncio",
"watchfiles",
"filelock",
]
def setup_logging(
log_level: str,
reme_log_level: str,
log_dir: str | None = None,
):
"""Configure logging for the eval runner and reme internals.
Args:
log_level: Level for the eval runner logger (DEBUG/INFO/WARNING/ERROR).
reme_log_level: Level for reme's internal loguru logger.
log_dir: Per-run log directory (absolute path). None = no file logging.
"""
numeric = getattr(logging, log_level.upper(), logging.INFO)
# Eval runner logger
logging.getLogger().setLevel(numeric)
logger.setLevel(numeric)
# Suppress noisy library loggers when above DEBUG
if numeric > logging.DEBUG:
for name in _NOISY_LOGGERS:
lib_logger = logging.getLogger(name)
lib_logger.setLevel(max(numeric, logging.WARNING))
# Add file handler for eval runner if log_dir is specified
if log_dir:
os.makedirs(log_dir, exist_ok=True)
log_filepath = os.path.join(log_dir, "runner.log")
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(numeric)
file_handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
logging.getLogger().addHandler(file_handler)
logger.info(f"Eval runner log file: {log_filepath}")
# Reme internal logger (loguru) — will be applied per-worker via _configure_worker
os.environ["REME_LOG_LEVEL"] = reme_log_level.upper()
if log_dir:
os.environ["REME_LOG_DIR"] = log_dir
def _configure_worker(
log_level: str,
reme_log_level: str,
log_dir: str | None = None,
):
"""Set up logging inside a multiprocessing worker process.
Must be called at the top of each worker because child processes inherit
parent state but loguru sinks are NOT shared across fork/spawn.
"""
numeric = getattr(logging, log_level.upper(), logging.INFO)
logging.basicConfig(level=numeric, format=_DEFAULT_LOG_FORMAT, force=True)
logging.getLogger("longmemeval").setLevel(numeric)
if numeric > logging.DEBUG:
for name in _NOISY_LOGGERS:
logging.getLogger(name).setLevel(max(numeric, logging.WARNING))
# Add file handler for eval runner in worker process
if log_dir:
os.makedirs(log_dir, exist_ok=True)
pid = os.getpid()
log_filepath = os.path.join(log_dir, f"worker-{pid}.log")
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(numeric)
file_handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
logging.getLogger().addHandler(file_handler)
# Re-initialize loguru for reme internals at the desired level
from reme.utils import get_logger
reme_log_dir = log_dir or "logs"
get_logger(log_dir=reme_log_dir, level=reme_log_level.upper(), force_init=True)
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_eval_config(config_path: str | None = None) -> dict:
"""Load evaluation config yaml with env-var expansion."""
if config_path is None:
config_path = str(Path(__file__).parent / "config.yaml")
with open(config_path, encoding="utf-8") as f:
raw = f.read()
# Expand ${VAR} and ${VAR:-default}
def _expand(m):
expr = m.group(1)
if ":-" in expr:
key, default = expr.split(":-", 1)
return os.environ.get(key, default)
return os.environ.get(expr, "")
raw = re.sub(r"\$\{([^}]+)\}", _expand, raw)
return yaml.safe_load(raw)
def create_reme_app(config: str = "benchmark", **overrides):
"""Create an app with the LongMemEval candidate and judge plugins enabled.
Plugin discovery remains environment-based; editable installation keeps local
plugin source changes visible to every multiprocessing worker.
"""
from reme import Application
from reme.config import resolve_app_config
enabled_plugins = list(overrides.pop("plugins", ()) or ())
for plugin in ("lme", "lme-judge"):
if plugin not in enabled_plugins:
enabled_plugins.append(plugin)
app_config = resolve_app_config(config=config, plugins=enabled_plugins, **overrides)
return Application(**app_config)
# ---------------------------------------------------------------------------
# Date utilities
# ---------------------------------------------------------------------------
def parse_haystack_date(date_str: str) -> datetime:
"""Parse LongMemEval date format: '2023/05/20 (Sat) 02:21' -> datetime."""
m = re.match(r"(\d{4}/\d{2}/\d{2})\s+\(\w+\)\s+(\d{2}:\d{2})", date_str)
if not m:
raise ValueError(f"Cannot parse haystack date: {date_str!r}")
return datetime.strptime(f"{m.group(1)} {m.group(2)}", "%Y/%m/%d %H:%M")
def to_iso(dt: datetime) -> str:
"""Convert datetime to ISO-8601 string precise to seconds."""
return dt.strftime("%Y-%m-%dT%H:%M:%S")
def should_trigger_dream(prev_dt: datetime, curr_dt: datetime, _trigger_hour: int = 23) -> bool:
"""Check if the time gap between two sessions crosses trigger_hour (e.g. 23:00)."""
if prev_dt.date() == curr_dt.date():
return False
# There's at least one midnight crossing; check if trigger_hour is between them
# Simple heuristic: if dates differ, dream should run for the previous day
return True
def sessions_sorted_by_time(item: dict) -> list[tuple[int, datetime, str, list[dict]]]:
"""Return (original_index, parsed_datetime, session_id, messages) sorted by time."""
entries = []
for i, (date_str, sid, msgs) in enumerate(
zip(item["haystack_dates"], item["haystack_session_ids"], item["haystack_sessions"]),
):
dt = parse_haystack_date(date_str)
entries.append((i, dt, sid, msgs))
# Sort by time (ascending)
entries.sort(key=lambda x: x[1])
return entries
# ---------------------------------------------------------------------------
# Message formatting
# ---------------------------------------------------------------------------
def format_messages_for_reme(messages: list[dict], session_dt: datetime) -> list[dict]:
"""Convert LongMemEval messages to ReMe auto_memory format.
Adds: name, created_at (ISO seconds). All messages in a session share the
same created_at (the session timestamp).
"""
formatted = []
for msg in messages:
role = msg["role"]
formatted.append(
{
"name": role,
"role": role,
"content": msg["content"],
"created_at": to_iso(session_dt),
},
)
return formatted
# ---------------------------------------------------------------------------
# LLM-as-Judge (delegated to answer_judge_step via app.run_job)
# ---------------------------------------------------------------------------
async def judge_response_via_job(
app,
question: str,
ground_truth: str,
response: str,
question_type: str,
) -> dict:
"""Use the answer_judge_step to evaluate a response against the golden answer."""
judge_resp = await app.run_job(
"answer_judge",
query=question,
agent_answer=response,
golden_answer=ground_truth,
question_type=question_type,
)
verdict = (judge_resp.answer or "").strip().lower()
raw_answer = (judge_resp.metadata or {}).get("raw_answer_judgement", "")
return {
"verdict": verdict,
"reason": raw_answer if verdict not in ("yes", "no") else "",
"metric": "binary",
"question_type": question_type,
}
# ---------------------------------------------------------------------------
# Main evaluation pipeline
# ---------------------------------------------------------------------------
async def evaluate_item(item: dict, eval_config: dict, item_index: int, eval_only: bool = False) -> dict:
"""Evaluate a single LongMemEval item end-to-end.
Args:
item: The dataset item containing question, answer, sessions, etc.
eval_config: The evaluation configuration dict.
item_index: The index of this item in the dataset.
eval_only: If True, skip ingestion (phases 1-3) and only run query+judge
using the existing workspace. Useful for re-evaluating different query
configurations without re-ingesting sessions.
"""
from reme.utils.evaluation_interface import track_agent_token_usage, track_job_counts
reme_cfg = eval_config["reme"]
dream_trigger_hour = reme_cfg.get("dream_trigger_hour", 23)
dream_scan_days = reme_cfg.get("dream_scan_days", 2)
dream_max_units = reme_cfg.get("dream_max_units", 5)
# Sort sessions by time
sorted_sessions = sessions_sorted_by_time(item)
# Filter out sessions that occur after question_date (if enabled)
filter_future = eval_config["evaluation"].get("filter_future_sessions", True)
if filter_future and item.get("question_date"):
question_dt = parse_haystack_date(item["question_date"])
total_before_filter = len(sorted_sessions)
sorted_sessions = [(i, dt, sid, msgs) for i, dt, sid, msgs in sorted_sessions if dt <= question_dt]
if len(sorted_sessions) < total_before_filter:
logger.info(
f"[Item {item_index}] Filtered sessions: {total_before_filter} -> {len(sorted_sessions)} "
f"(removed {total_before_filter - len(sorted_sessions)} future sessions "
f"after question_date={item['question_date']})",
)
logger.info(
"[Item %s] question_id=%s type=%s sessions=%d%s",
item_index,
item["question_id"],
item["question_type"],
len(sorted_sessions),
" [eval_only]" if eval_only else "",
)
# Use fixed workspace directory (clean it for fresh evaluation)
workspace_root = _PROJECT_ROOT / eval_config["dataset"].get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
item_dir = workspace_root / f"item_{item_index}"
workspace_dir = str(item_dir / ".reme")
if eval_only:
if not item_dir.exists() or not Path(workspace_dir).exists():
raise FileNotFoundError(
f"[Item {item_index}] eval_only: workspace not found at {item_dir}. "
f"Run without --eval_only first to build the workspace.",
)
else:
if item_dir.exists():
shutil.rmtree(item_dir)
logger.info(f"[Item {item_index}] Cleaned existing workspace: {item_dir}")
else:
logger.info(f"[Item {item_index}] Workspace not found, creating: {item_dir}")
item_dir.mkdir(parents=True, exist_ok=True)
# Pre-initialize ReMe's loguru logger with the correct log_dir
# (singleton — Application.__init__ will reuse this instance)
output_cfg = eval_config.get("output", {})
if output_cfg.get("log_to_file", False):
reme_log_dir = os.environ.get("REME_LOG_DIR")
if reme_log_dir:
from reme.utils import get_logger
get_logger(
log_dir=reme_log_dir,
level=os.environ.get("REME_LOG_LEVEL", "INFO"),
log_to_console=output_cfg.get("log_to_console", True),
log_to_file=True,
force_init=True,
)
app = create_reme_app(
config=reme_cfg["config"],
plugins=reme_cfg.get("plugins", ()),
workspace_dir=workspace_dir,
log_to_console=output_cfg.get("log_to_console", True),
log_to_file=output_cfg.get("log_to_file", False),
enable_logo=False,
)
await app.start()
try:
dream_dates_triggered = set()
dream_available = True # Set to False if auto_dream job is not found
if not eval_only:
# ── Phase 1: Ingest sessions ──────────────────────────────
prev_dt = None
for idx, (_, session_dt, session_id, messages) in enumerate(sorted_sessions):
# Check if dream should be triggered before this session
if (
dream_available
and prev_dt is not None
and should_trigger_dream(prev_dt, session_dt, dream_trigger_hour)
):
dream_date = prev_dt.strftime("%Y-%m-%d")
if dream_date not in dream_dates_triggered:
logger.info(f"[Item {item_index}] Triggering dream for date={dream_date}")
try:
dream_resp = await app.run_job(
"auto_dream",
date=dream_date,
scan_days=dream_scan_days,
max_units=dream_max_units,
)
logger.info(
f"[Item {item_index}] Dream done: success={dream_resp.success} "
f"answer={dream_resp.answer[:100] if dream_resp.answer else ''}",
)
except Exception as e:
if "not found" in str(e).lower():
dream_available = False
logger.warning(f"[Item {item_index}] auto_dream job not found, skipping all dreams")
else:
logger.warning(f"[Item {item_index}] Dream failed for {dream_date}: {e}")
dream_dates_triggered.add(dream_date)
# Index update after dream to pick up new digest nodes
await app.run_job("index_update")
# Format and ingest the session
formatted_msgs = format_messages_for_reme(messages, session_dt)
date_str = session_dt.strftime("%Y-%m-%d")
logger.info(
f"[Item {item_index}] Ingesting session {idx+1}/{len(sorted_sessions)} "
f"id={session_id} date={date_str} msgs={len(formatted_msgs)}",
)
resp = await app.run_job(
"auto_memory",
messages=formatted_msgs,
session_id=session_id,
date=date_str,
)
if not resp.success:
logger.warning(
f"[Item {item_index}] auto_memory failed for session {session_id}: {resp.answer}",
)
# Manual index update after each session
await app.run_job("index_update")
prev_dt = session_dt
# ── Phase 2: Final dream for the last day ─────────────────
if dream_available and prev_dt is not None:
last_dream_date = prev_dt.strftime("%Y-%m-%d")
if last_dream_date not in dream_dates_triggered:
logger.info(f"[Item {item_index}] Final dream for date={last_dream_date}")
try:
await app.run_job(
"auto_dream",
date=last_dream_date,
scan_days=dream_scan_days,
max_units=dream_max_units,
)
except Exception as e:
if "not found" in str(e).lower():
dream_available = False
logger.warning(f"[Item {item_index}] auto_dream job not found, skipping all dreams")
else:
logger.warning(f"[Item {item_index}] Final dream failed: {e}")
dream_dates_triggered.add(last_dream_date)
# Index update after final dream
await app.run_job("index_update")
# ── Phase 3: Digest update ────────────────────────────────
await app.run_job("digest_update")
# ── Phase 4: Ask question via agentic_answer job (ReAct agent) ──
question = item["question"]
compress_session = bool(eval_config["evaluation"].get("compress_session", False))
question_date_raw = item.get("question_date", "")
question_dt = parse_haystack_date(question_date_raw) if question_date_raw else None
query_time = to_iso(question_dt) if question_dt else ""
logger.info(
f"[Item {item_index}] Asking (agentic): {question[:80]}... query_time={query_time}",
)
with (
track_job_counts(["search"], app.context) as tool_counts,
track_agent_token_usage(
["bench"],
app.context,
) as token_usages,
):
query_resp = await app.run_job(
"agentic_answer",
query=question,
query_time=query_time,
compress_session=compress_session,
)
agentic_tool_counts = tool_counts
agentic_token_usage = token_usages["bench"]
agentic_response = (query_resp.answer or "").strip()
if not agentic_response:
agentic_response = "(no answer generated)"
logger.info(f"[Item {item_index}] Agentic response: {agentic_response[:200]}...")
logger.info(f"[Item {item_index}] Agentic tool calls: {agentic_tool_counts}")
logger.info(f"[Item {item_index}] Bench token usage: {agentic_token_usage}")
# ── Phase 5: Judge agentic response (via answer_judge_step) ──────────
logger.info(f"[Item {item_index}] Judging agentic (binary, type={item['question_type']})...")
agentic_judgment = await judge_response_via_job(
app=app,
question=question,
ground_truth=item["answer"],
response=agentic_response,
question_type=item["question_type"],
)
logger.info(f"[Item {item_index}] agentic binary result: {agentic_judgment}")
finally:
await app.close()
return {
"question_id": item["question_id"],
"question_type": item["question_type"],
"question": question,
"ground_truth": item["answer"],
"agentic_response": agentic_response,
"agentic_judgment": agentic_judgment,
"agentic_tool_counts": agentic_tool_counts,
"agentic_token_usage": agentic_token_usage,
"sessions_ingested": len(sorted_sessions),
"dreams_triggered": len(dream_dates_triggered),
}
# ---------------------------------------------------------------------------
# Worker: runs a single item in its own process with its own event loop
# ---------------------------------------------------------------------------
def _evaluate_item_worker(task_input: tuple) -> dict:
"""Worker function for multiprocessing. Each process gets its own event loop."""
item, eval_config, item_index, log_level, reme_log_level, eval_only, log_dir = task_input
import asyncio # pylint: disable=import-outside-toplevel
_configure_worker(log_level, reme_log_level, log_dir=log_dir)
# Permanently suppress "Task exception was never retrieved" /
# "Event loop is closed" noise from httpx AsyncClient GC cleanup.
# These fire AFTER asyncio.run() closes the loop, during Python's
# garbage collection of httpx connection-pool tasks — harmless.
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
return asyncio.run(evaluate_item(item, eval_config, item_index, eval_only=eval_only))
def _indexed_worker(indexed_input: tuple) -> tuple:
"""Module-level wrapper for imap_unordered with index tracking."""
idx, task_input = indexed_input
return idx, _evaluate_item_worker(task_input)
def _resolve_num_workers(configured: int) -> int:
"""Resolve num_workers: 0=auto (cpu_count-2, min 1), 1=sequential, >1=parallel."""
if configured == 0:
return max(1, (os.cpu_count() or 4) - 2)
return max(1, configured)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(
config_path: str | None = None,
log_level: str = "INFO",
reme_log_level: str = "INFO",
eval_only: bool = False,
):
"""Run the LongMemEval evaluation pipeline.
Args:
config_path: Path to the YAML config file.
log_level: Log level for the eval runner.
reme_log_level: Log level for reme internal logs.
eval_only: If True, skip ingestion and only run query+judge using
existing workspaces.
"""
from multiprocessing import Pool # pylint: disable=import-outside-toplevel
# Load config BEFORE logging setup so log_dir is available
eval_config = load_eval_config(config_path)
# Resolve per-run log directory from config
output_cfg = eval_config.get("output", {})
log_dir_abs = None
if output_cfg.get("log_to_file", False):
log_dir_raw = output_cfg.get("log_dir", "logs")
log_prefix = output_cfg.get("log_prefix", "longmemeval")
run_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_dir_abs = str(_PROJECT_ROOT / log_dir_raw / f"{log_prefix}_{run_ts}")
setup_logging(log_level, reme_log_level, log_dir=log_dir_abs)
dataset_cfg = eval_config["dataset"]
# Load dataset
dataset_path = _PROJECT_ROOT / dataset_cfg["path"]
logger.info(f"Loading dataset from {dataset_path}")
with open(dataset_path, encoding="utf-8") as f:
data = json.load(f)
start = dataset_cfg.get("start_index", 0)
num_items = dataset_cfg.get("num_items", 0)
if num_items > 0:
raw_items = data[start : start + num_items]
else:
raw_items = data[start:]
# Build item list
items_with_idx = [(start + i, item) for i, item in enumerate(raw_items)]
# Filter by question_type if specified
question_types = dataset_cfg.get("question_types") or []
if question_types:
before_filter = len(items_with_idx)
items_with_idx = [(idx, item) for idx, item in items_with_idx if item.get("question_type") in question_types]
logger.info(
f"Filtered by question_types={question_types}: {before_filter} -> {len(items_with_idx)} items",
)
# Filter by question_id if specified
question_ids = dataset_cfg.get("question_ids") or []
if question_ids:
qid_set = set(question_ids)
before_filter = len(items_with_idx)
items_with_idx = [(idx, item) for idx, item in items_with_idx if item.get("question_id") in qid_set]
logger.info(
f"Filtered by question_ids ({len(qid_set)} ids): {before_filter} -> {len(items_with_idx)} items",
)
logger.info(
"Evaluating %d item(s) starting from index %d%s",
len(items_with_idx),
start,
" [eval_only: query+judge only]" if eval_only else "",
)
# Resolve parallelism
num_workers = _resolve_num_workers(eval_config["evaluation"].get("num_workers", 1))
logger.info(f"Using {num_workers} worker(s)")
# Create output directory
output_dir = _PROJECT_ROOT / output_cfg.get("dir", "benchmark/longmemeval/results")
output_dir.mkdir(parents=True, exist_ok=True)
# Create workspace root directory
workspace_root = _PROJECT_ROOT / dataset_cfg.get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
workspace_root.mkdir(parents=True, exist_ok=True)
# Pre-check: verify all workspaces exist in eval_only mode
if eval_only:
missing_items = []
for orig_idx, _ in items_with_idx:
item_dir = workspace_root / f"item_{orig_idx}"
if not item_dir.exists() or not (item_dir / ".reme").exists():
missing_items.append(orig_idx)
if missing_items:
preview = missing_items[:10]
suffix = "..." if len(missing_items) > 10 else ""
raise FileNotFoundError(
f"eval_only: {len(missing_items)} workspace(s) not found under {workspace_root}. "
f"Missing item indices: {preview}{suffix}. "
f"Run without --eval_only first to build the workspaces.",
)
# Build task args — include log levels, eval_only flag, and log paths (use original index for workspace lookup)
task_args = [
(item, eval_config, orig_idx, log_level, reme_log_level, eval_only, log_dir_abs)
for orig_idx, item in items_with_idx
]
# Progress tracking (force print regardless of log level, every 10 minutes)
total_items = len(task_args)
completed_count = [0] # use list for mutability in closure
start_time = time.time()
progress_lock = threading.Lock()
def _print_progress(prefix: str = "PROGRESS"):
elapsed = time.time() - start_time
elapsed_min = elapsed / 60
done = completed_count[0]
pct = 100.0 * done / total_items if total_items else 0
eta_str = "N/A"
if done > 0:
eta_sec = elapsed / done * (total_items - done)
eta_str = f"{eta_sec/60:.1f}min"
print(
f"[{prefix}] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | "
f"{done}/{total_items} ({pct:.1f}%) completed | "
f"elapsed={elapsed_min:.1f}min | ETA={eta_str}",
flush=True,
)
def _progress_timer():
"""Background thread: print progress every 10 minutes."""
while not _timer_stop.is_set():
_timer_stop.wait(600) # 10 minutes
if not _timer_stop.is_set():
with progress_lock:
_print_progress()
_timer_stop = threading.Event()
timer_thread = threading.Thread(target=_progress_timer, daemon=True)
timer_thread.start()
# Run evaluation
if num_workers == 1:
# Sequential mode
results = []
for task_input in task_args:
result = _evaluate_item_worker(task_input)
results.append(result)
with progress_lock:
completed_count[0] += 1
else:
# Parallel mode — use imap_unordered for progress tracking
results = [None] * total_items
indexed_args = list(enumerate(task_args))
with Pool(processes=num_workers) as pool:
for idx, result in pool.imap_unordered(_indexed_worker, indexed_args):
results[idx] = result
with progress_lock:
completed_count[0] += 1
# Stop progress timer
_timer_stop.set()
timer_thread.join(timeout=2)
# Save results
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = output_dir / f"results_{timestamp}.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info(f"Results saved to {output_file}")
# Final progress
_print_progress("FINAL")
_print_summary(results, start_time)
# ---------------------------------------------------------------------------
# Summary printing
# ---------------------------------------------------------------------------
def _print_summary(results: list[dict], start_time: float) -> None:
"""Print per-item verdicts and per-type accuracy."""
print("\n" + "=" * 60)
print("EVALUATION RESULTS")
print("=" * 60)
def _accumulate(judgment_key):
correct = 0
stats: dict = {} # {question_type: {correct: int, total: int}}
for r in results:
qtype = r["question_type"]
verdict = r.get(judgment_key, {}).get("verdict", "N/A")
if qtype not in stats:
stats[qtype] = {"correct": 0, "total": 0}
stats[qtype]["total"] += 1
if verdict == "yes":
correct += 1
stats[qtype]["correct"] += 1
return correct, stats
agentic_correct, agentic_type_stats = _accumulate("agentic_judgment")
total = len(results)
# Per-item verdict rows
for r in results:
a_verdict = r.get("agentic_judgment", {}).get("verdict", "N/A")
print(f" [{r['question_id']}] type={r['question_type']} agentic={a_verdict}")
print("\n" + "-" * 60)
print(f" Items: {total}")
# Agentic stats
print("\n ── Agentic (ReAct) ──")
print(f" Overall accuracy: {agentic_correct}/{total} ({100*agentic_correct/total:.1f}%)")
tool_call_totals = [sum(r.get("agentic_tool_counts", {}).values()) for r in results]
tool_call_mean, tool_call_std = _mean_and_std(tool_call_totals)
print(f" Tool calls/query: mean={tool_call_mean:.2f} std={tool_call_std:.2f}")
token_usages = [r.get("agentic_token_usage", {}) for r in results]
print(" Bench reported tokens/query:")
for metric in _TOKEN_USAGE_METRICS:
values = [usage[metric] for usage in token_usages if usage.get(metric) is not None]
if values:
mean, std = _mean_and_std(values)
print(f" {metric}: mean={mean:.2f} std={std:.2f}")
else:
print(f" {metric}: unavailable")
print(" Per-type accuracy:")
for qtype, stats in sorted(agentic_type_stats.items()):
acc = 100 * stats["correct"] / stats["total"] if stats["total"] else 0
print(f" {qtype}: {stats['correct']}/{stats['total']} ({acc:.1f}%)")
print("=" * 60)
total_elapsed = time.time() - start_time
print(f"\n Total time: {total_elapsed/60:.1f} min")
print("\n" + "=" * 60)
print(" [DONE] EVALUATION COMPLETED SUCCESSFULLY")
print("=" * 60 + "\n")
_TOKEN_USAGE_METRICS = (
"input_tokens",
"output_tokens",
"total_tokens",
)
def _mean_and_std(values: list[int]) -> tuple[float, float]:
"""Return population mean and standard deviation for one per-query metric."""
if not values:
return 0.0, 0.0
mean = sum(values) / len(values)
return mean, (sum((value - mean) ** 2 for value in values) / len(values)) ** 0.5
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="LongMemEval evaluation runner")
parser.add_argument("--config", type=str, default=None, help="Path to config.yaml")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level for the eval runner (default: INFO)",
)
parser.add_argument(
"--reme-log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level for reme internal logs — loguru (default: INFO)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Shortcut for --log-level WARNING --reme-log-level WARNING",
)
parser.add_argument(
"--eval_only",
action="store_true",
help="Skip ingestion (phases 1-3). Reuse existing workspaces and only run query+judge.",
)
args = parser.parse_args()
if args.quiet:
args.log_level = "WARNING"
args.reme_log_level = "WARNING"
main(args.config, args.log_level, args.reme_log_level, eval_only=args.eval_only)