mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
* chore(benchmark): isolate dataset/workspaces/results per benchmark
- Move shared benchmark/{datasets,memory_workspaces,results} into per-benchmark subdirs benchmark/<name>/{dataset,workspaces,results}
- Update beam/longmemeval config.yaml and run.py path defaults
- Relocate longmemeval download.py to benchmark/longmemeval/ (downloads into dataset/ subdir); inline dataset download docs into README
- Update .gitignore: benchmark/*/{dataset,workspaces,results}/
- Move result-{beam,longmemeval}.md to benchmark/results_md/ and drop result- prefix; update README links
- Fix stale path refs in llm_judge.py and logs/demo_search_format.py
* feat(benchmark): add read tool to agentic answer and update BEAM results
- Add 'read' to job_tools in BaseAgenticAnswerStep for file reading capability
- Document read tool usage in lme/agentic_answer.yaml system prompt
- Update result-beam.md with latest evaluation scores (OVERALL: 0.623/0.580)
* feat(auto_memory): add source line-number markers for note traceability
- Add _format_history hook in AutoMemoryStep with line-number annotation
- Override in BeamAutoMemoryStep to prefix each turn with [Ln] for citation
- Add session_file variable to prompt templates for source marker paths
- Simplify repeated extraction rules by referencing system prompt
- Enhance agentic_answer search strategy (multi-search, read tool hint)
- Add warning log on ReadStep failure
* feat(beam): enhance auto_memory with source markers and pilot ingest tooling
* refactor(beam): rename max_chunk_words to max_segment_words, drop one-off pilot scripts
* feat: add CompressorStep and search_v2 dual-mode session compression
- Add CompressorStep (reme/steps/evolve/compressor.py) for direct LLM
text compression with optional query-guided relevance filtering
- Extend search_v2_step to support query-aware and query-independent
session transcript compression via _compress injected kwargs
- Refactor _source_format.py: split into render_chunk_entries +
join_chunk_entries; session chunks now render line-aligned with
L<n>: prefixes for verbatim/compressed parity
- Add JOB_TOOLS and INJECTED_JOB_KWARGS to BaseAgenticAnswerStep for
per-subclass tool and parameter injection
- LmeAgenticAnswerStep injects _search._compress payload to enable
query-aware compression during benchmark evaluation
- Record compression ablation results in result-longmemeval.md
- Add unit tests for CompressorStep and search compression paths
* refactor(compress): relax session compression to lenient format-preserving strategy and update LME results
* refactor(benchmark): make session compression config-driven via compress_session flag
Move session-transcript compression from LME hard-coded injection to a
runtime context flag set by evaluation.compress_session in each
benchmark config. Compression is off by default for both BEAM and LME,
and BaseAgenticAnswerStep now conditionally injects the _search compress
payload only when the flag is truthy.
* feat(lme/auto_memory): add source attribution markers with line numbers
Add _format_history to annotate each turn with [Ln] line numbers and
expose {session_file} in prompts so the agent can emit bare wikilink-style
source markers like [[session/dialog/s1.jsonl#L1-L2,L5-L6]] at the end
of factual entries. Consolidate the per-prompt body/format rules into
references to the system prompt to avoid drift, and add frontmatter-
protection guidance for the edit tool.
* feat: improve agentic answer prompt and update beam 100K results
- Strengthen abstention rule: prohibit extrapolation from related but
non-direct evidence
- Add multi-angle search after preliminary answer to check for
conflicting/supplementary/updated information
- Add max-iteration fallback to 'Information not found'
- Update beam.md with 100K results (agentscope 2.0.4.post1, from scratch)
including per-type token consumption and memory construction stats
- config.yaml: 100K dataset, 20 workers for BEAM evaluation
- run.py: add memory construction token usage tracking (default agent)
- Overall: 0.635 → 0.654 (+0.019), contradiction_resolution: 0.338 → 0.478
(+0.140), abstention: 0.500 → 0.525 (+0.025)
* feat(read): add session-aware formatting for read tool and update BEAM eval
- Add truncate_session_output in _file_io.py to render jsonl session
lines as [speaker @ time] content before byte-budget truncation
- Add read_step_format_session flag to ReadStep, honoring injected
job kwargs (precedence) and YAML fallback
- Inject read_step_format_session=True into BaseAgenticAnswerStep
so agentic answer reads render session transcripts human-readably
- Refine BEAM agentic_answer prompt: continue multi-angle search
after preliminary answer, forbid fabrication/extrapolation
- Update BEAM config to 1M variant and add sequential 100K-eval /
1M-build shell script
- Refresh benchmark/results_md/beam.md with latest results
* chore(config): disable expand_links in beam and lme search_v2 configs
* refactor(beam): drop one-off sequential 100K-eval-then-1M-build script
* fix(benchmark): add compressor job to beam config and fix BEAM clone instructions
- Add compressor job and compressor as_llm component to reme/config/beam.yaml
(aligned with lme.yaml) so that compress_session: true works for BEAM
- Add graceful degradation guard in search_v2._compress_session_entries:
when the compressor job is missing from the active config, log a warning
and skip compression instead of raising 'Job compressor not found'.
Skipped when there is no app_context so unit tests mocking run_job still
drive compression behavior.
- Fix BEAM download instructions in README.md/README_ZH.md: add mkdir -p
before cd benchmark/beam/dataset (the directory is gitignored and absent
in a fresh clone)
* fix(steps): guard compressor exceptions and fix ReadStep boolean override
1. search_v2: catch per-entry exceptions from run_job('compressor') inside
compress() so asyncio.gather never propagates a compressor failure (e.g.
temporary LLM outage). The failing entry keeps its original body while
remaining entries are still compressed, preserving already-retrieved
search results.
2. read: replace 'context_value or yaml_value' with an existence check so
that a runtime-injected False can explicitly disable a YAML-true
read_step_format_session flag.
Add focused unit tests for both paths.
* fix(search_v2): use existence check for strict_date_filter boolean override
Replace 'context_value or yaml_value' with an existence-based check so
that a runtime-injected False can explicitly disable a YAML-true
strict_date_filter flag, consistent with the read_step_format_session fix.
* refactor(search): simplify strict_date_filter fallback to truthiness-or
* style(test): rename unused param to satisfy pylint W0613
---------
Co-authored-by: sa-buc <jiangniurou.xyf@dail-algo011164204033.ET135>
891 lines
34 KiB
Python
891 lines
34 KiB
Python
"""BEAM evaluation runner for ReMe.
|
|
|
|
Evaluates ReMe's memory capability using the BEAM dataset.
|
|
Each case gets an isolated workspace; chat.json batches are ingested as
|
|
sessions in chronological order; finally probing questions are answered
|
|
via an agentic (ReAct) approach, then
|
|
judged by BEAM's rubric-based LLM-as-judge.
|
|
|
|
Usage:
|
|
python benchmark/beam/run.py
|
|
python benchmark/beam/run.py --config benchmark/beam/config.yaml
|
|
python benchmark/beam/run.py -q # quiet: only eval-level logs
|
|
python benchmark/beam/run.py --log-level WARNING # reduce eval runner logs
|
|
python benchmark/beam/run.py --reme-log-level WARNING # reduce reme internal logs
|
|
python benchmark/beam/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__).parent.parent.parent
|
|
load_dotenv(_PROJECT_ROOT / ".env")
|
|
|
|
# Workspace root — read from config.yaml (dataset.workspace_root)
|
|
_WORKSPACE_ROOT_DEFAULT = "benchmark/beam/workspaces/beam"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
_DEFAULT_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(message)s"
|
|
|
|
logging.basicConfig(level=logging.INFO, format=_DEFAULT_LOG_FORMAT)
|
|
logger = logging.getLogger("beam")
|
|
|
|
# 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("beam").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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BEAM data loading
|
|
# ---------------------------------------------------------------------------
|
|
def parse_beam_time_anchor(time_str: str) -> datetime:
|
|
"""Parse BEAM time_anchor format: 'March-15-2024' -> datetime."""
|
|
for fmt in ("%B-%d-%Y", "%b-%d-%Y"):
|
|
try:
|
|
return datetime.strptime(time_str, fmt)
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(f"Cannot parse time_anchor: {time_str!r}")
|
|
|
|
|
|
def load_beam_chat(chat_path: Path, chat_size: str, case_id: str) -> list[dict]:
|
|
"""Load BEAM chat.json and convert to ReMe session format.
|
|
|
|
Each batch becomes one session with all its turns flattened.
|
|
Each turn resolves its own time_anchor independently; turns without
|
|
an explicit time_anchor inherit from the most recent preceding turn.
|
|
Returns list of sessions, each with:
|
|
- session_id: str
|
|
- date: str (YYYY-MM-DD) — derived from the *first* turn's time
|
|
- messages: list[dict] with name, role, content, created_at
|
|
"""
|
|
with open(chat_path, encoding="utf-8") as f:
|
|
batches = json.load(f)
|
|
|
|
sessions = []
|
|
for batch in batches:
|
|
batch_num = batch["batch_number"]
|
|
|
|
# Resolve batch-level fallback (used when no turn has a time_anchor)
|
|
batch_anchor = batch.get("time_anchor")
|
|
if not batch_anchor:
|
|
batch_anchor = "January-1-2024"
|
|
|
|
# Flatten all turns, resolving time_anchor per turn
|
|
messages = []
|
|
prev_dt = None # carries forward from previous turn
|
|
first_dt = None # for session-level date
|
|
|
|
for turn in batch["turns"]:
|
|
# Find this turn's own time_anchor from its messages
|
|
turn_anchor = None
|
|
for msg in turn:
|
|
if msg.get("time_anchor"):
|
|
turn_anchor = msg["time_anchor"]
|
|
break
|
|
|
|
if turn_anchor:
|
|
dt = parse_beam_time_anchor(turn_anchor)
|
|
elif prev_dt is not None:
|
|
dt = prev_dt # inherit from previous turn
|
|
else:
|
|
dt = parse_beam_time_anchor(batch_anchor)
|
|
|
|
if first_dt is None:
|
|
first_dt = dt
|
|
prev_dt = dt
|
|
|
|
for msg in turn:
|
|
role = msg["role"]
|
|
messages.append(
|
|
{
|
|
"name": role,
|
|
"role": role,
|
|
"content": msg["content"],
|
|
"created_at": dt.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
},
|
|
)
|
|
|
|
sessions.append(
|
|
{
|
|
"session_id": f"beam_{chat_size}_{case_id}_batch{batch_num}",
|
|
"date": first_dt.strftime("%Y-%m-%d"),
|
|
"messages": messages,
|
|
},
|
|
)
|
|
|
|
return sessions
|
|
|
|
|
|
def get_available_cases(beam_root: Path, chat_size: str) -> list[str]:
|
|
"""Return sorted list of case IDs for a given chat size."""
|
|
chats_dir = beam_root / "chats" / chat_size
|
|
if not chats_dir.exists():
|
|
return []
|
|
return sorted(
|
|
[d.name for d in chats_dir.iterdir() if d.is_dir()],
|
|
key=int,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Answer generation
|
|
# ---------------------------------------------------------------------------
|
|
async def answer_question_agentic(app, question: str, compress_session: bool = False) -> tuple[str, dict]:
|
|
"""Answer a probing question using ReMe's agentic_answer job.
|
|
|
|
Returns (answer, metadata)
|
|
"""
|
|
from reme.utils.evaluation_interface import track_agent_token_usage, track_job_counts
|
|
|
|
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,
|
|
compress_session=compress_session,
|
|
)
|
|
answer = (query_resp.answer or "").strip()
|
|
|
|
return answer, {
|
|
"mode": "agentic",
|
|
"tool_counts": tool_counts,
|
|
"token_usage": token_usages["bench"],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BEAM rubric-based LLM-as-Judge
|
|
# ---------------------------------------------------------------------------
|
|
async def judge_answer(
|
|
app,
|
|
question: str,
|
|
llm_response: str,
|
|
rubric: list[str],
|
|
question_type: str = "",
|
|
) -> dict:
|
|
"""Judge an answer via the answer_judge job (beam_rubric_judge_step)."""
|
|
judge_resp = await app.run_job(
|
|
"answer_judge",
|
|
llm_response=llm_response,
|
|
rubric=rubric,
|
|
probing_question=question,
|
|
question_type=question_type,
|
|
)
|
|
result = {
|
|
"llm_judge_score": (judge_resp.metadata or {}).get("llm_judge_score", 0.0),
|
|
"llm_judge_responses": (judge_resp.metadata or {}).get("llm_judge_responses", []),
|
|
}
|
|
# Include event_ordering extra metrics if present
|
|
eo = (judge_resp.metadata or {}).get("event_ordering")
|
|
if eo:
|
|
result["event_ordering"] = eo
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main evaluation pipeline
|
|
# ---------------------------------------------------------------------------
|
|
async def evaluate_case(eval_config: dict, case_id: str, eval_only: bool = False) -> dict:
|
|
"""Evaluate a single BEAM case end-to-end.
|
|
|
|
Args:
|
|
eval_config: The evaluation configuration dict.
|
|
case_id: The case directory name (e.g. "1").
|
|
eval_only: If True, skip ingestion and only run query+judge
|
|
using the existing workspace.
|
|
|
|
Returns:
|
|
A results dict with all questions, answers, and judgments.
|
|
"""
|
|
from reme import Application
|
|
from reme.config import resolve_app_config
|
|
|
|
dataset_cfg = eval_config["dataset"]
|
|
chat_size = dataset_cfg["chat_size"]
|
|
compress_session = bool(eval_config["evaluation"].get("compress_session", False))
|
|
beam_root = _PROJECT_ROOT / dataset_cfg.get("beam_root", "benchmark/beam/dataset/BEAM")
|
|
chat_path = beam_root / "chats" / chat_size / case_id / "chat.json"
|
|
probing_questions_path = beam_root / "chats" / chat_size / case_id / "probing_questions" / "probing_questions.json"
|
|
|
|
if not chat_path.exists():
|
|
raise FileNotFoundError(f"Chat file not found: {chat_path}")
|
|
if not probing_questions_path.exists():
|
|
raise FileNotFoundError(f"Probing questions not found: {probing_questions_path}")
|
|
|
|
logger.info(
|
|
"[Case %s] size=%s%s",
|
|
case_id,
|
|
chat_size,
|
|
" [eval_only]" if eval_only else "",
|
|
)
|
|
|
|
# Workspace setup
|
|
workspace_root = _PROJECT_ROOT / dataset_cfg.get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
|
|
case_dir = workspace_root / f"{chat_size}_{case_id}"
|
|
workspace_dir = str(case_dir / ".reme")
|
|
|
|
if eval_only:
|
|
if not case_dir.exists() or not Path(workspace_dir).exists():
|
|
raise FileNotFoundError(
|
|
f"[Case {case_id}] eval_only: workspace not found at {case_dir}. "
|
|
f"Run without --eval_only first to build the workspace.",
|
|
)
|
|
else:
|
|
if case_dir.exists():
|
|
shutil.rmtree(case_dir)
|
|
logger.info(f"[Case {case_id}] Cleaned existing workspace: {case_dir}")
|
|
else:
|
|
logger.info(f"[Case {case_id}] Workspace not found, creating: {case_dir}")
|
|
case_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Pre-initialize ReMe's loguru logger with the correct log_dir
|
|
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,
|
|
)
|
|
|
|
cfg = resolve_app_config(
|
|
config=eval_config["reme"]["config"],
|
|
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,
|
|
)
|
|
|
|
app = Application(**cfg)
|
|
await app.start()
|
|
|
|
from reme.utils.evaluation_interface import check_agent_token_usage # noqa: E402
|
|
|
|
_MEM_AGENT_NAMES = ("default", "bench")
|
|
sessions_ingested = 0
|
|
memory_token_usage: dict[str, dict[str, int | None]] = {}
|
|
try:
|
|
if not eval_only:
|
|
# ── Phase 1: Ingest sessions (with token tracking) ─────────
|
|
sessions = load_beam_chat(chat_path, chat_size, case_id)
|
|
logger.info(f"[Case {case_id}] Loaded {len(sessions)} sessions from chat.json")
|
|
|
|
# Snapshot token counters before memory construction
|
|
mem_token_start = {name: check_agent_token_usage(name, app.context) for name in _MEM_AGENT_NAMES}
|
|
|
|
for i, session in enumerate(sessions):
|
|
logger.info(
|
|
f"[Case {case_id}] Ingesting session {i+1}/{len(sessions)}: "
|
|
f"id={session['session_id']} date={session['date']} "
|
|
f"msgs={len(session['messages'])}",
|
|
)
|
|
resp = await app.run_job(
|
|
"auto_memory",
|
|
messages=session["messages"],
|
|
session_id=session["session_id"],
|
|
date=session["date"],
|
|
)
|
|
if not resp.success:
|
|
logger.warning(f"[Case {case_id}] auto_memory failed: {resp.answer}")
|
|
else:
|
|
logger.info(
|
|
f"[Case {case_id}] auto_memory success: " f"{resp.answer[:100] if resp.answer else ''}",
|
|
)
|
|
await app.run_job("index_update")
|
|
sessions_ingested += 1
|
|
|
|
# Final digest update
|
|
logger.info(f"[Case {case_id}] Running digest_update...")
|
|
await app.run_job("digest_update")
|
|
logger.info(f"[Case {case_id}] Ingestion complete.")
|
|
|
|
# Compute memory construction token deltas
|
|
for name in _MEM_AGENT_NAMES:
|
|
end_usage = check_agent_token_usage(name, app.context)
|
|
delta: dict[str, int | None] = {}
|
|
for metric in _TOKEN_USAGE_METRICS:
|
|
current = end_usage[metric]
|
|
start = mem_token_start[name][metric]
|
|
delta[metric] = None if current is None else current - (start or 0)
|
|
memory_token_usage[name] = delta
|
|
logger.info(f"[Case {case_id}] Memory construction token usage: {memory_token_usage}")
|
|
|
|
# ── Phase 2: Answer + Judge probing questions ───────────────
|
|
with open(probing_questions_path, encoding="utf-8") as f:
|
|
probing_questions = json.load(f)
|
|
|
|
total_questions = sum(len(v) for v in probing_questions.values())
|
|
logger.info(f"[Case {case_id}] Total probing questions: {total_questions}")
|
|
|
|
all_question_results = []
|
|
q_idx = 0
|
|
|
|
for q_type in probing_questions:
|
|
logger.info(
|
|
f"[Case {case_id}] Question type: {q_type} " f"({len(probing_questions[q_type])} questions)",
|
|
)
|
|
|
|
for i, q in enumerate(probing_questions[q_type]):
|
|
q_idx += 1
|
|
question = q["question"]
|
|
rubric = q.get("rubric", [])
|
|
logger.info(
|
|
f"[Case {case_id}] [{q_idx}/{total_questions}] " f"{q_type} Q{i+1}: {question[:100]}...",
|
|
)
|
|
|
|
q_result = {
|
|
"question_type": q_type,
|
|
"question_index": i,
|
|
"question": question,
|
|
"rubric": rubric,
|
|
}
|
|
|
|
# Agentic answer
|
|
try:
|
|
agentic_answer, agentic_meta = await answer_question_agentic(
|
|
app,
|
|
question,
|
|
compress_session=compress_session,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[Case {case_id}] Agentic answer failed: {e}")
|
|
agentic_answer = f"(error: {e})"
|
|
agentic_meta = {"error": str(e)}
|
|
|
|
if not agentic_answer:
|
|
agentic_answer = "(no answer generated)"
|
|
logger.info(f"[Case {case_id}] Agentic answer: {agentic_answer[:200]}...")
|
|
logger.info(
|
|
f"[Case {case_id}] Agentic tool calls: {agentic_meta.get('tool_counts', {})}",
|
|
)
|
|
logger.info(f"[Case {case_id}] Bench token usage: {agentic_meta.get('token_usage', {})}")
|
|
|
|
# Judge agentic answer
|
|
logger.info(f"[Case {case_id}] Judging agentic ({q_type})...")
|
|
agentic_judgment = await judge_answer(
|
|
app,
|
|
question,
|
|
agentic_answer,
|
|
rubric,
|
|
question_type=q_type,
|
|
)
|
|
logger.info(
|
|
f"[Case {case_id}] Agentic score: " f"{agentic_judgment['llm_judge_score']:.3f}",
|
|
)
|
|
|
|
q_result["agentic_response"] = agentic_answer
|
|
q_result["agentic_judgment"] = agentic_judgment
|
|
q_result["agentic_metadata"] = agentic_meta
|
|
|
|
all_question_results.append(q_result)
|
|
|
|
finally:
|
|
await app.close()
|
|
|
|
return {
|
|
"case_id": case_id,
|
|
"chat_size": chat_size,
|
|
"sessions_ingested": sessions_ingested,
|
|
"total_questions": len(all_question_results),
|
|
"questions": all_question_results,
|
|
"memory_token_usage": memory_token_usage,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Worker: runs a single case in its own process with its own event loop
|
|
# ---------------------------------------------------------------------------
|
|
def _evaluate_case_worker(task_input: tuple) -> dict:
|
|
"""Worker function for multiprocessing. Each process gets its own event loop."""
|
|
eval_config, case_id, 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)
|
|
|
|
# Suppress httpx GC noise
|
|
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
|
|
|
return asyncio.run(evaluate_case(eval_config, case_id, 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_case_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( # pylint: disable=too-many-statements
|
|
config_path: str | None = None,
|
|
log_level: str = "INFO",
|
|
reme_log_level: str = "INFO",
|
|
eval_only: bool = False,
|
|
):
|
|
"""Run the BEAM 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", "beam")
|
|
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"]
|
|
chat_size = dataset_cfg["chat_size"]
|
|
beam_root = _PROJECT_ROOT / dataset_cfg.get("beam_root", "benchmark/beam/dataset/BEAM")
|
|
|
|
# Determine which cases to run
|
|
case_ids = dataset_cfg.get("case_ids") or []
|
|
if not case_ids:
|
|
case_ids = get_available_cases(beam_root, chat_size)
|
|
|
|
# Pagination
|
|
start = dataset_cfg.get("start_index", 0)
|
|
num_items = dataset_cfg.get("num_items", 0)
|
|
if num_items > 0:
|
|
case_ids = case_ids[start : start + num_items]
|
|
elif start > 0:
|
|
case_ids = case_ids[start:]
|
|
|
|
if not case_ids:
|
|
logger.error(f"No cases found for chat_size={chat_size}")
|
|
return
|
|
|
|
logger.info(
|
|
"Evaluating %d case(s) for chat_size=%s: %s%s",
|
|
len(case_ids),
|
|
chat_size,
|
|
case_ids,
|
|
" [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/beam/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_cases = []
|
|
for case_id in case_ids:
|
|
case_dir = workspace_root / f"{chat_size}_{case_id}"
|
|
if not case_dir.exists() or not (case_dir / ".reme").exists():
|
|
missing_cases.append(case_id)
|
|
if missing_cases:
|
|
preview = missing_cases[:10]
|
|
suffix = "..." if len(missing_cases) > 10 else ""
|
|
raise FileNotFoundError(
|
|
f"eval_only: {len(missing_cases)} workspace(s) not found under {workspace_root}. "
|
|
f"Missing cases: {preview}{suffix}. "
|
|
f"Run without --eval_only first to build the workspaces.",
|
|
)
|
|
|
|
# Build task args
|
|
task_args = [(eval_config, case_id, log_level, reme_log_level, eval_only, log_dir_abs) for case_id in case_ids]
|
|
|
|
# Progress tracking
|
|
total_items = len(task_args)
|
|
completed_count = [0]
|
|
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)
|
|
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:
|
|
results = []
|
|
for task_input in task_args:
|
|
result = _evaluate_case_worker(task_input)
|
|
results.append(result)
|
|
with progress_lock:
|
|
completed_count[0] += 1
|
|
else:
|
|
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_{chat_size}_{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 concise summary
|
|
print("\n" + "=" * 70)
|
|
print(f" BEAM EVALUATION RESULTS | size={chat_size} cases={len(results)}")
|
|
print("=" * 70)
|
|
|
|
# Per-type stats (agentic only)
|
|
type_scores: dict[str, list[float]] = {}
|
|
type_binary_scores: dict[str, list[float]] = {}
|
|
all_scores: list[float] = []
|
|
all_binary_scores: list[float] = []
|
|
all_tool_call_totals: list[int] = []
|
|
all_token_usages: list[dict[str, int | None]] = []
|
|
all_memory_token_usages: list[dict[str, dict[str, int | None]]] = []
|
|
|
|
for case_result in results:
|
|
if "error" in case_result:
|
|
continue
|
|
mem_usage = case_result.get("memory_token_usage", {})
|
|
if mem_usage:
|
|
all_memory_token_usages.append(mem_usage)
|
|
for q in case_result.get("questions", []):
|
|
judgment = q.get("agentic_judgment", {})
|
|
score = judgment.get("llm_judge_score", 0.0)
|
|
# Binary: convert each rubric item score to 0/1, then average
|
|
judge_responses = judgment.get("llm_judge_responses", [])
|
|
if judge_responses:
|
|
binary_scores_per_item = [1.0 if r.get("score", 0) >= 1.0 else 0.0 for r in judge_responses]
|
|
binary_score = sum(binary_scores_per_item) / len(binary_scores_per_item)
|
|
else:
|
|
binary_score = 1.0 if score > 0.99 else 0.0
|
|
qtype = q["question_type"]
|
|
if qtype not in type_scores:
|
|
type_scores[qtype] = []
|
|
type_binary_scores[qtype] = []
|
|
type_scores[qtype].append(score)
|
|
type_binary_scores[qtype].append(binary_score)
|
|
all_scores.append(score)
|
|
all_binary_scores.append(binary_score)
|
|
metadata = q.get("agentic_metadata", {})
|
|
all_tool_call_totals.append(sum(metadata.get("tool_counts", {}).values()))
|
|
all_token_usages.append(metadata.get("token_usage", {}))
|
|
|
|
# Memory construction token usage summary
|
|
if all_memory_token_usages:
|
|
print("\n ── Memory Construction Token Usage ──")
|
|
for agent_name in ("default", "bench"):
|
|
for metric in _TOKEN_USAGE_METRICS:
|
|
values = [
|
|
usage[agent_name][metric]
|
|
for usage in all_memory_token_usages
|
|
if usage.get(agent_name, {}).get(metric) is not None
|
|
]
|
|
if values:
|
|
total = sum(values)
|
|
mean, std = _mean_and_std(values)
|
|
print(
|
|
f" {agent_name}/{metric}: total={total} mean={mean:.2f} std={std:.2f} ({len(values)} cases)",
|
|
)
|
|
else:
|
|
print(f" {agent_name}/{metric}: unavailable")
|
|
print()
|
|
|
|
print("\n ── AGENTIC ──")
|
|
if all_scores:
|
|
for qtype in sorted(type_scores.keys()):
|
|
scores = type_scores[qtype]
|
|
avg = sum(scores) / len(scores) if scores else 0
|
|
bin_scores = type_binary_scores[qtype]
|
|
bin_avg = sum(bin_scores) / len(bin_scores) if bin_scores else 0
|
|
print(f" {qtype:<40s}: {avg:.3f} binary={bin_avg:.3f} ({len(scores)} Qs)")
|
|
overall = sum(all_scores) / len(all_scores) if all_scores else 0
|
|
binary_overall = sum(all_binary_scores) / len(all_binary_scores) if all_binary_scores else 0
|
|
print(f" {'-'*38}")
|
|
print(f" {'OVERALL':<40s}: {overall:.3f} binary={binary_overall:.3f} ({len(all_scores)} Qs)")
|
|
tool_call_mean, tool_call_std = _mean_and_std(all_tool_call_totals)
|
|
print(f" Tool calls/query: mean={tool_call_mean:.2f} std={tool_call_std:.2f}")
|
|
print(" Bench reported tokens/query:")
|
|
for metric in _TOKEN_USAGE_METRICS:
|
|
values = [usage[metric] for usage in all_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")
|
|
else:
|
|
print(" (no results)")
|
|
|
|
# Per-case summary
|
|
print("\n ── Per-Case Summary ──")
|
|
for case_result in results:
|
|
case_id = case_result["case_id"]
|
|
if "error" in case_result:
|
|
print(f" Case {case_id}: ERROR — {case_result['error']}")
|
|
continue
|
|
n_qs = case_result.get("total_questions", 0)
|
|
n_sessions = case_result.get("sessions_ingested", 0)
|
|
mem_usage = case_result.get("memory_token_usage", {})
|
|
parts = [f"Case {case_id}: {n_sessions} sessions, {n_qs} questions"]
|
|
# Append memory construction total tokens if available
|
|
for agent_name in ("default", "bench"):
|
|
agent_usage = mem_usage.get(agent_name, {})
|
|
total = agent_usage.get("total_tokens")
|
|
if total is not None:
|
|
parts.append(f"mem_{agent_name}_tokens={total}")
|
|
questions = case_result.get("questions", [])
|
|
scores = [q.get("agentic_judgment", {}).get("llm_judge_score", 0.0) for q in questions]
|
|
if scores:
|
|
avg = sum(scores) / len(scores)
|
|
# Binary: 0/1 per rubric item, average per question, then across questions
|
|
bin_scores = []
|
|
for q in questions:
|
|
judge_responses = q.get("agentic_judgment", {}).get("llm_judge_responses", [])
|
|
if judge_responses:
|
|
item_bins = [1.0 if r.get("score", 0) >= 1.0 else 0.0 for r in judge_responses]
|
|
bin_scores.append(sum(item_bins) / len(item_bins))
|
|
else:
|
|
s = q.get("agentic_judgment", {}).get("llm_judge_score", 0.0)
|
|
bin_scores.append(1.0 if s > 0.99 else 0.0)
|
|
bin_avg = sum(bin_scores) / len(bin_scores)
|
|
parts.append(f"agentic={avg:.3f} binary={bin_avg:.3f}")
|
|
print(f" {' | '.join(parts)}")
|
|
|
|
print("=" * 70)
|
|
total_elapsed = time.time() - start_time
|
|
print(f"\n Total time: {total_elapsed/60:.1f} min")
|
|
print("\n" + "=" * 70)
|
|
print(" [DONE] BEAM EVALUATION COMPLETED SUCCESSFULLY")
|
|
print("=" * 70 + "\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-question 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="BEAM 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. 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)
|