mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-14 23:21:04 +00:00
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
831 lines
33 KiB
Python
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)
|