Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

510 lines
20 KiB
Python

"""Run one (model, task, mode) triple. Output a single result row.
Usage:
python -m benchmarks.model_eval.runner \\
--model qwen3:4b-instruct-2507-q4_K_M \\
--task room_classification \\
--mode closed \\
--dataset-dir benchmarks/model_eval/datasets
Designed to be called by orchestrator.py for matrix runs, but standalone
runnable for one-off debugging.
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
from mempalace.llm_client import LLMError, LLMResponse, get_provider
from .metrics import (
HostInfo,
TimingAggregate,
TimingSample,
VRAMPoller,
aggregate_timings,
embed_text,
extract_timing,
gather_host_info,
strip_thinking_tokens,
vram_resident_mb,
)
from .tasks.calibration import prompts as cal_prompts, score as cal_score
from .tasks.entity_extraction import prompts as ent_prompts, score as ent_score
from .tasks.memory_extraction import prompts as mem_prompts, score as mem_score
from .tasks.room_classification import prompts as rc_prompts, score as rc_score
@dataclass
class Result:
model_tag: str
task: str
mode: str
accuracy: float
extras: dict
timing: TimingAggregate
vram_resident_mb: Optional[int]
vram_peak_mb: Optional[int]
host: HostInfo
run_date: str
n_samples: int
error: Optional[str] = None
language: str = "en"
_EMBED_MODEL = "embeddinggemma"
# Language code validator. Accepts ISO-639-style codes with optional region
# subtag (en, pt-BR, zh-CN, fr_CA, etc.). Strict pattern is required because
# `language` is interpolated into the dataset filename — without validation a
# caller could pass values containing path separators or `..` and the loader
# would read files outside the task directory.
_LANGUAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+)?$")
# Tasks that score via semantic similarity and require an embedding model.
_EMBED_TASKS: set[tuple[str, str]] = {
("memory_extraction", "default"),
("room_classification", "open"),
}
def _ensure_embed_model(endpoint: str, model: str = _EMBED_MODEL) -> None:
"""Verify the embedding model is available; pull it automatically if not.
Raises RuntimeError with a clear message if the model cannot be made available.
"""
if embed_text("ping", model=model, endpoint=endpoint) is not None:
return
print(f" Embedding model '{model}' not found — pulling automatically...", file=sys.stderr, flush=True)
# Pass OLLAMA_HOST so the pull targets the same endpoint being benchmarked,
# not the default localhost:11434.
env = {**os.environ, "OLLAMA_HOST": endpoint}
try:
result = subprocess.run(
["ollama", "pull", model],
env=env,
capture_output=True,
text=True,
timeout=300,
)
except FileNotFoundError:
raise RuntimeError(
f"'ollama' not found on PATH. Install Ollama and ensure it is on PATH, "
f"then run 'ollama pull {model}' manually."
)
if result.returncode != 0:
raise RuntimeError(
f"Embedding model '{model}' is required for this task but could not be pulled. "
f"Run 'ollama pull {model}' manually and retry.\n"
f"ollama stderr: {result.stderr.strip()}"
)
if embed_text("ping", model=model, endpoint=endpoint) is None:
raise RuntimeError(
f"Embedding model '{model}' was pulled but is still not responding on {endpoint}. "
f"Check Ollama logs."
)
print(f" Embedding model '{model}' ready.", file=sys.stderr, flush=True)
def load_jsonl(path: Path) -> list[dict]:
with open(path, "r", encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def _classify_with_timing(
provider, system: str, user: str, json_mode: bool
) -> tuple[Optional[LLMResponse], TimingSample, Optional[str]]:
"""Run one classify call. Always disables thinking on hybrid models.
MemPalace classification tasks (room, entity, memory) never benefit
from extended reasoning. Forcing think=False keeps hybrid Qwen 3
models in fast-instruct mode, gives pure-instruct models a no-op,
and ensures the benchmark measures the real production code path.
"""
t0 = time.perf_counter()
try:
response = provider.classify(system=system, user=user, json_mode=json_mode, think=False)
except LLMError as e:
return None, TimingSample(0, 0, 0, 0, 0), str(e)
elapsed = time.perf_counter() - t0
timing = extract_timing(response.raw, elapsed)
return response, timing, None
def _build_prompt(task: str, mode: str, sample: dict, label: dict) -> tuple[str, str, bool]:
"""Return (system, user, json_mode)."""
if task == "calibration":
return cal_prompts.SYSTEM, cal_prompts.build_user_prompt(sample["text"], sample["classes"]), False
if task == "room_classification":
if mode == "closed":
return (
rc_prompts.CLOSED_SYSTEM,
rc_prompts.build_closed_user(sample["agent"], sample["session_summary"], sample["__rooms__"]),
False,
)
return (
rc_prompts.OPEN_SYSTEM,
rc_prompts.build_open_user(sample["agent"], sample["session_summary"]),
False,
)
if task == "entity_extraction":
return ent_prompts.SYSTEM, ent_prompts.build_user(sample["text"]), True
if task == "memory_extraction":
return mem_prompts.SYSTEM, mem_prompts.build_user(sample["text"]), True
raise ValueError(f"Unknown task: {task}")
def _score_one(task: str, mode: str, predicted: str, sample: dict, label: dict, endpoint: str, embed_model: str = _EMBED_MODEL) -> dict:
if task == "calibration":
return cal_score.score(predicted, label["label"], sample["classes"])
if task == "room_classification":
if mode == "closed":
return rc_score.score_closed(predicted, label["closed_set_label"], sample["__rooms__"])
return rc_score.score_open(predicted, label["preferred_open_label"], embed_model=embed_model, endpoint=endpoint)
if task == "entity_extraction":
return ent_score.score(predicted, label["entities"])
if task == "memory_extraction":
return mem_score.score(predicted, label["memories"], embed_model=embed_model, endpoint=endpoint)
raise ValueError(f"Unknown task: {task}")
def _aggregate_accuracy(task: str, mode: str, scores: list[dict]) -> tuple[float, dict]:
"""Return (primary_accuracy, extras_dict)."""
if not scores:
return 0.0, {}
if task == "calibration" or (task == "room_classification" and mode == "closed"):
correct = sum(1 for s in scores if s.get("correct"))
return correct / len(scores), {
"correct": correct,
"total": len(scores),
}
if task == "room_classification" and mode == "open":
sims = [s.get("similarity", 0.0) for s in scores]
exacts = sum(1 for s in scores if s.get("exact_match"))
mean_sim = sum(sims) / len(sims)
return mean_sim, {
"mean_similarity": mean_sim,
"exact_match_count": exacts,
"high_similarity_count": sum(1 for s in sims if s >= 0.8),
"low_similarity_count": sum(1 for s in sims if s < 0.5),
}
if task == "entity_extraction":
f1 = sum(s.get("f1", 0.0) for s in scores) / len(scores)
precision = sum(s.get("precision", 0.0) for s in scores) / len(scores)
recall = sum(s.get("recall", 0.0) for s in scores) / len(scores)
valid_json_rate = sum(1 for s in scores if s.get("valid_json")) / len(scores)
return f1, {
"mean_f1": f1,
"mean_precision": precision,
"mean_recall": recall,
"valid_json_rate": valid_json_rate,
}
if task == "memory_extraction":
coverage = sum(s.get("coverage", 0.0) for s in scores) / len(scores)
hallucination = sum(s.get("hallucination_rate", 0.0) for s in scores) / len(scores)
type_accuracy = sum(s.get("type_accuracy", 0.0) for s in scores) / len(scores)
valid_json_rate = sum(1 for s in scores if s.get("valid_json")) / len(scores)
return coverage, {
"mean_coverage": coverage,
"mean_hallucination_rate": hallucination,
"mean_type_accuracy": type_accuracy,
"valid_json_rate": valid_json_rate,
}
return 0.0, {}
def run(
model_tag: str,
task: str,
mode: str,
dataset_dir: Path,
endpoint: str = "http://localhost:11434",
warmup: int = 1,
n_samples: Optional[int] = None,
strip_thinking: bool = True,
llm_provider: str = "ollama",
embed_endpoint: str = "http://localhost:11434",
embed_model: str = _EMBED_MODEL,
language: str = "en",
num_ctx: Optional[int] = None,
) -> Result:
"""Run one (model, task, mode) triple. Returns a Result.
When language != "en", loads `dataset.{language}.jsonl` instead of `dataset.jsonl`.
Labels and room_lists are always loaded from the English files — non-English inputs
are scored against the same English ground truth (cross-lingual mapping test).
"""
host = gather_host_info()
run_date = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
if not _LANGUAGE_RE.match(language):
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Invalid language code: {language!r}. Expected pattern like 'en', 'pt-BR', 'zh-CN'.",
)
task_dir = dataset_dir / task
dataset_file = "dataset.jsonl" if language == "en" else f"dataset.{language}.jsonl"
dataset_path = task_dir / dataset_file
# Belt-and-suspenders: even with the regex guard above, confirm the resolved
# path lives inside task_dir before opening anything on disk.
try:
if not dataset_path.resolve().is_relative_to(task_dir.resolve()):
raise ValueError("resolved path escapes task_dir")
except (ValueError, OSError) as e:
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Refused to load dataset outside task_dir: {dataset_path} ({e})",
)
if not dataset_path.exists():
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Dataset not found: {dataset_path}",
)
samples = load_jsonl(dataset_path)
# Prefer language-specific labels when present (memory_extraction scoring
# compares model output against ground truth — when the model extracts in
# the input language but the ground truth stays English, cosine similarity
# collapses to noise). Fall back to English with an explicit log so the
# source of any score gap is visible to whoever reads results.
lang_labels = task_dir / f"labels.{language}.jsonl"
if language != "en" and lang_labels.exists():
labels_path = lang_labels
else:
labels_path = task_dir / "labels.jsonl"
if language != "en":
print(
f" info: language={language} labels=labels.jsonl "
f"(no labels.{language}.jsonl found — scoring against English ground truth)",
file=sys.stderr, flush=True,
)
labels = load_jsonl(labels_path)
if len(samples) != len(labels):
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Sample/label count mismatch: {len(samples)} vs {len(labels)}",
)
if task == "room_classification":
room_lists = load_jsonl(task_dir / "room_lists.jsonl")
if len(room_lists) != len(samples):
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Room-list count mismatch: {len(room_lists)} vs {len(samples)}",
)
for s, rl in zip(samples, room_lists):
s["__rooms__"] = rl["rooms"]
if n_samples is not None:
samples = samples[:n_samples]
labels = labels[:n_samples]
if (task, mode) in _EMBED_TASKS:
try:
_ensure_embed_model(embed_endpoint, model=embed_model)
except (RuntimeError, subprocess.TimeoutExpired, FileNotFoundError) as e:
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Embedding model unavailable: {e}",
)
try:
provider_kwargs: dict = {}
if num_ctx is not None:
provider_kwargs["num_ctx"] = num_ctx
provider = get_provider(llm_provider, model=model_tag, endpoint=endpoint, timeout=180, **provider_kwargs)
except LLMError as e:
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Provider init failed: {e}",
)
if warmup > 0 and samples:
s0, l0 = samples[0], labels[0]
try:
sys_p, user_p, json_mode = _build_prompt(task, mode, s0, l0)
for _ in range(warmup):
provider.classify(system=sys_p, user=user_p, json_mode=json_mode, think=False)
except LLMError as e:
return Result(
model_tag=model_tag, task=task, mode=mode,
accuracy=0.0, extras={}, timing=aggregate_timings([]),
vram_resident_mb=None, vram_peak_mb=None, host=host,
run_date=run_date, n_samples=0, language=language,
error=f"Warmup failed: {e}",
)
poller = VRAMPoller()
poller.start()
timings: list[TimingSample] = []
scores: list[dict] = []
errors: list[str] = []
for sample, label in zip(samples, labels):
sys_p, user_p, json_mode = _build_prompt(task, mode, sample, label)
response, timing, err = _classify_with_timing(provider, sys_p, user_p, json_mode)
if err is not None:
errors.append(err)
continue
text = response.text
if strip_thinking:
text = strip_thinking_tokens(text, response.raw)
timings.append(timing)
try:
score_result = _score_one(task, mode, text, sample, label, embed_endpoint, embed_model=embed_model)
scores.append(score_result)
except Exception as e:
errors.append(f"score error: {e}")
peak_vram = poller.stop()
# vram_resident_mb queries Ollama's /api/ps; meaningless for non-Ollama providers.
if llm_provider == "ollama":
resident_vram = vram_resident_mb(model_tag, endpoint=endpoint)
else:
resident_vram = None
accuracy, extras = _aggregate_accuracy(task, mode, scores)
if errors:
extras["error_count"] = len(errors)
extras["error_sample"] = errors[0]
return Result(
model_tag=model_tag,
task=task,
mode=mode,
accuracy=accuracy,
extras=extras,
timing=aggregate_timings(timings),
vram_resident_mb=resident_vram,
vram_peak_mb=peak_vram,
host=host,
run_date=run_date,
n_samples=len(scores),
language=language,
)
def main():
parser = argparse.ArgumentParser(description="Run one (model, task, mode) benchmark triple")
parser.add_argument("--model", required=True, help="Ollama model tag")
parser.add_argument("--task", required=True, choices=["room_classification", "entity_extraction", "memory_extraction", "calibration"])
parser.add_argument("--mode", default="closed", choices=["closed", "open", "default"], help="Mode: closed/open for room_classification, 'default' otherwise")
parser.add_argument("--dataset-dir", required=True, type=Path, help="Path to the bench dataset root")
parser.add_argument("--endpoint", default="http://localhost:11434")
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--n", type=int, default=None, help="Limit to first N samples (for debugging)")
parser.add_argument("--no-strip-thinking", action="store_true")
parser.add_argument("--llm-provider", default="ollama",
choices=["ollama", "openai-compat", "anthropic"],
help="LLM provider (default: ollama)")
parser.add_argument("--embed-endpoint", default=None,
help="Endpoint for the embedding model (always Ollama). "
"When omitted: defaults to --endpoint if --llm-provider=ollama, "
"else http://localhost:11434.")
parser.add_argument("--language", default="en",
help="Dataset language suffix. 'en' loads dataset.jsonl; "
"other values load dataset.{lang}.jsonl (e.g. pt-BR, es, zh).")
parser.add_argument("--embed-model", default=_EMBED_MODEL,
help=f"Embedding model for semantic-similarity scoring "
f"(memory_extraction, room_classification:open). "
f"Default: {_EMBED_MODEL}.")
parser.add_argument("--num-ctx", type=int, default=4096,
help="Ollama context window per request (sent as options.num_ctx). "
"Defaults to 4096 so every candidate runs at the same window regardless "
"of its Modelfile default — without this, a 32k-default model pre-allocates "
"KV cache that a 4k-default model doesn't, and accuracy/latency/VRAM stop "
"being comparable. Pass a larger value if a task prompt exceeds 4k tokens.")
args = parser.parse_args()
if args.task != "room_classification" and args.mode in ("closed", "open"):
args.mode = "default"
# Resolve --embed-endpoint default: it lives on Ollama always, but should
# follow --endpoint when the LLM provider is Ollama so a remote benchmark
# run scores against the same host.
if args.embed_endpoint is None:
args.embed_endpoint = args.endpoint if args.llm_provider == "ollama" else "http://localhost:11434"
result = run(
model_tag=args.model,
task=args.task,
mode=args.mode,
dataset_dir=args.dataset_dir,
endpoint=args.endpoint,
warmup=args.warmup,
n_samples=args.n,
strip_thinking=not args.no_strip_thinking,
llm_provider=args.llm_provider,
embed_endpoint=args.embed_endpoint,
language=args.language,
embed_model=args.embed_model,
num_ctx=args.num_ctx,
)
print(json.dumps(_result_to_dict(result), indent=2))
if result.error:
sys.exit(1)
def _result_to_dict(r: Result) -> dict:
return {
"model_tag": r.model_tag,
"task": r.task,
"mode": r.mode,
"language": r.language,
"n_samples": r.n_samples,
"accuracy": round(r.accuracy, 4),
"extras": r.extras,
"timing": asdict(r.timing),
"vram_resident_mb": r.vram_resident_mb,
"vram_peak_mb": r.vram_peak_mb,
"host": asdict(r.host),
"run_date": r.run_date,
"error": r.error,
}
if __name__ == "__main__":
main()