SearchGen-Bench / scripts /build_leaderboard_data.py
wufangtai
Adopt ten-component benchmark protocol
a8782a2
Raw
History Blame Contribute Delete
16.9 kB
#!/usr/bin/env python3
"""Build deterministic SearchGen-Bench prompt and aggregate data artifacts."""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
OVERALL_9_COMPONENTS = [
"checklist",
"rubric_adaptive",
"prompt_faithfulness",
"image_quality",
"text_rendering",
"ai_naturalness",
"composition_and_aesthetics",
"physical_plausibility",
"visual_reference_evaluation",
]
OVERALL_9_EXCLUDED_COMPONENT = "text_reference_evaluation"
DISPLAY_COMPONENTS = [*OVERALL_9_COMPONENTS, OVERALL_9_EXCLUDED_COMPONENT]
MODEL_METADATA = {
"bagel": ("Bagel", "Open"),
"klein4b": ("Flux.2-Klein-4B", "Open"),
"klein": ("Flux.2-Klein-9B", "Open"),
"qwen1": ("Qwen-Image", "Open"),
"imagen3fast": ("Imagen3-Fast", "Commercial"),
"qwen2": ("Qwen-Image-2", "Commercial"),
"qwen_image_2_pro": ("Qwen-Image-2-Pro", "Commercial"),
"jimeng4d0": ("SeedDream-4.0", "Commercial"),
"seedream4d5": ("SeedDream-4.5", "Commercial"),
"xai_image": ("Grok-Imagine-Image", "Commercial"),
"gemini2d5flash": ("Nano Banana", "Commercial"),
"gemini3pro": ("Nano Banana Pro", "Commercial"),
"gpt_image": ("GPT-Image-2", "Commercial"),
}
# Paper Table 1 uses the validated replacement Qwen-Image-2 run. Other uses of
# the legacy `qwen2` ID in ToolGen (for example Table 2) intentionally remain
# separate, so the source-directory mapping is local to this public leaderboard.
MODEL_SOURCE_IDS = {"qwen2": "qwen-image-2.0"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--toolgen-root",
type=Path,
default=Path(os.environ["TOOLGEN_ROOT"]) if "TOOLGEN_ROOT" in os.environ else None,
help="ToolGen checkout containing paper_materials and final_20k_release_v2",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).resolve().parents[1] / "public" / "data",
)
parser.add_argument(
"--generated-at",
help="ISO timestamp for reproducible rebuilds (defaults to SOURCE_DATE_EPOCH or now)",
)
args = parser.parse_args()
if args.toolgen_root is None:
parser.error("--toolgen-root or TOOLGEN_ROOT is required")
return args
def load_canonical_module(toolgen_root: Path):
candidates = [
toolgen_root / "neurips_paper_materials" / "recompute_tables.py",
toolgen_root / "paper_materials" / "recompute_tables.py",
]
module_path = next((path for path in candidates if path.is_file()), None)
if module_path is None:
raise FileNotFoundError(f"Canonical scorer not found in: {', '.join(map(str, candidates))}")
spec = importlib.util.spec_from_file_location("searchgen_recompute_tables", module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to import canonical scorer: {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def source_commit(toolgen_root: Path) -> str | None:
try:
return subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=toolgen_root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return None
def generated_at(value: str | None) -> str:
if value:
return value
epoch = os.environ.get("SOURCE_DATE_EPOCH")
if epoch:
return datetime.fromtimestamp(int(epoch), tz=timezone.utc).isoformat()
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def mean_present(components: dict[str, float | None], keys: list[str]) -> float:
values = [components[key] for key in keys if components.get(key) is not None]
return sum(values) / len(values) if values else 0.0
def locate_result(row_dir: Path, model_id: str) -> tuple[str, Path | None]:
candidate_dirs = [row_dir / "none" / f"{model_id}_generator", row_dir / "none" / model_id]
existing_dir = False
for model_dir in candidate_dirs:
if model_dir.is_dir():
existing_dir = True
result_path = model_dir / "augmented_parsed_result_ffjudge_pp.json"
if result_path.is_file():
return "present", result_path
return ("missing_evaluation" if existing_dir else "missing_generation"), None
def load_score(row_dir: Path, model_id: str, canonical: Any) -> dict[str, Any]:
source_id = MODEL_SOURCE_IDS.get(model_id, model_id)
status, result_path = locate_result(row_dir, source_id)
if result_path is None:
return {"status": status, "lane": "none", "components_raw_0to3": None}
try:
payload = json.loads(result_path.read_text())
parsed = payload.get("parsed", {})
if not parsed:
raise ValueError("missing parsed result")
components = canonical.extract_10comp(parsed)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return {"status": "invalid_evaluation", "lane": "none", "components_raw_0to3": None}
return {
"status": "scored",
"lane": "none",
"components_raw_0to3": components,
"overall_10_raw": mean_present(components, list(canonical.COMPONENT_KEYS)),
"overall_9_raw": mean_present(components, OVERALL_9_COMPONENTS),
}
def classify_prompt(row: dict[str, Any]) -> tuple[str, str]:
if row.get("subset") == "NoSearch":
return "NoSearch", "NoSearch"
sample_id = row["sample_id"]
if "texthard" in sample_id or "text_rendering" in sample_id:
return "SearchIntensive", "TextualSearch"
return "SearchIntensive", "VisualSearch"
def build_records(eval_rows: list[dict[str, Any]], canonical: Any) -> list[dict[str, Any]]:
records = []
for index, row in enumerate(eval_rows):
stratum, search_type = classify_prompt(row)
row_dir = canonical.RELEASE_ROOT / row["release_row"]
models = {
model_id: load_score(row_dir, model_id, canonical)
for model_id in canonical.TABLE1_GENS
}
records.append(
{
"sample_id": row["sample_id"],
"prompt_index": index,
"release_row": row["release_row"],
"original_subset": row.get("subset"),
"stratum": stratum,
"search_type": search_type,
"domains": sorted(set(row.get("domains", []))),
"failure_modes": sorted(set(row.get("failure_modes", []))),
"difficulty": row.get("difficulty"),
"language": row.get("language"),
"generation_task_type": row.get("generation_task_type"),
"is_miniset": bool(row.get("is_miniset")),
"models": models,
}
)
return records
def aggregate_group(
records: list[dict[str, Any]],
model_ids: list[str],
skip_missing: set[str],
) -> list[dict[str, Any]]:
output = []
for model_id in model_ids:
scored = [r["models"][model_id] for r in records if r["models"][model_id]["status"] == "scored"]
missing_policy = "exclude" if model_id in skip_missing else "zero_fill"
n_total = len(records)
n_scored = len(scored)
n_included = n_scored if missing_policy == "exclude" else n_total
overall_9_values = [s["overall_9_raw"] for s in scored]
overall_10_values = [s["overall_10_raw"] for s in scored]
if missing_policy == "zero_fill":
overall_9_values.extend([0.0] * (n_total - n_scored))
overall_10_values.extend([0.0] * (n_total - n_scored))
component_scores = {}
component_counts = {}
for component in DISPLAY_COMPONENTS:
values = [
s["components_raw_0to3"][component]
for s in scored
if s["components_raw_0to3"].get(component) is not None
]
if missing_policy == "zero_fill":
values.extend([0.0] * (n_total - n_scored))
component_counts[component] = len(values)
component_scores[component] = round((sum(values) / len(values)) * 100 / 3, 1) if values else None
display_name, model_type = MODEL_METADATA[model_id]
output.append(
{
"model_id": model_id,
"display_name": display_name,
"type": model_type,
"n_total": n_total,
"n_scored": n_scored,
"n_included": n_included,
"coverage": round(n_scored / n_total, 4) if n_total else 0.0,
"missing_policy": missing_policy,
"overall_9": round((sum(overall_9_values) / len(overall_9_values)) * 100 / 3, 1)
if overall_9_values
else None,
"overall_10": round((sum(overall_10_values) / len(overall_10_values)) * 100 / 3, 1)
if overall_10_values
else None,
"components": component_scores,
"component_counts": component_counts,
}
)
return sorted(output, key=lambda row: (-(row["overall_10"] or -1), row["display_name"]))
def build_aggregates(records: list[dict[str, Any]], canonical: Any) -> dict[str, Any]:
model_ids = list(canonical.TABLE1_GENS)
skip_missing = set(canonical.SKIP_MISSING_GENS)
selectors: dict[str, Callable[[dict[str, Any]], bool]] = {
"All": lambda _: True,
"NoSearch": lambda row: row["stratum"] == "NoSearch",
"SearchIntensive": lambda row: row["stratum"] == "SearchIntensive",
"VisualSearch": lambda row: row["search_type"] == "VisualSearch",
"TextualSearch": lambda row: row["search_type"] == "TextualSearch",
}
strata = {
name: aggregate_group([r for r in records if selector(r)], model_ids, skip_missing)
for name, selector in selectors.items()
}
domains = sorted({tag for row in records for tag in row["domains"]})
failure_modes = sorted({tag for row in records for tag in row["failure_modes"]})
by_domain = {
tag: aggregate_group([r for r in records if tag in r["domains"]], model_ids, skip_missing)
for tag in domains
}
by_failure_mode = {
tag: aggregate_group([r for r in records if tag in r["failure_modes"]], model_ids, skip_missing)
for tag in failure_modes
}
return {
"overall": strata["All"],
"strata": strata,
"domains": by_domain,
"failure_modes": by_failure_mode,
}
def validate(records: list[dict[str, Any]], aggregates: dict[str, Any], canonical: Any) -> None:
errors = []
sample_ids = [r["sample_id"] for r in records]
if len(records) != 751:
errors.append(f"expected 751 prompts, found {len(records)}")
if len(set(sample_ids)) != len(sample_ids):
errors.append("sample_id values are not unique")
counts = {
"NoSearch": sum(r["stratum"] == "NoSearch" for r in records),
"SearchIntensive": sum(r["stratum"] == "SearchIntensive" for r in records),
"VisualSearch": sum(r["search_type"] == "VisualSearch" for r in records),
"TextualSearch": sum(r["search_type"] == "TextualSearch" for r in records),
}
expected = {"NoSearch": 100, "SearchIntensive": 651, "VisualSearch": 387, "TextualSearch": 264}
if counts != expected:
errors.append(f"partition mismatch: {counts} != {expected}")
for row in records:
if len(row["domains"]) != len(set(row["domains"])) or len(row["failure_modes"]) != len(set(row["failure_modes"])):
errors.append(f"duplicate tag in {row['sample_id']}")
for model_id, score in row["models"].items():
components = score.get("components_raw_0to3")
if score["status"] != "scored":
continue
for key, value in components.items():
if value is not None and not 0 <= value <= 3:
errors.append(f"out-of-range score {row['sample_id']} {model_id} {key}={value}")
expected_9 = mean_present(components, OVERALL_9_COMPONENTS)
expected_10 = mean_present(components, list(canonical.COMPONENT_KEYS))
if abs(score["overall_9_raw"] - expected_9) > 1e-12 or abs(score["overall_10_raw"] - expected_10) > 1e-12:
errors.append(f"overall recomputation mismatch for {row['sample_id']} {model_id}")
# Ensure the exported paper metric matches the canonical aggregation helper.
full_by_model = {row["model_id"]: row for row in aggregates["overall"]}
for model_id in canonical.TABLE1_GENS:
score_list = []
for record in records:
score = record["models"][model_id]
if score["status"] == "scored":
score_list.append(score["components_raw_0to3"])
elif model_id not in canonical.SKIP_MISSING_GENS:
score_list.append(canonical.zero_fill())
expected_overall = round(canonical.aggregate_components(score_list)["overall"] * 100 / 3, 1)
actual = full_by_model[model_id]["overall_10"]
if actual != expected_overall:
errors.append(f"Overall-10 mismatch for {model_id}: {actual} != {expected_overall}")
if errors:
raise ValueError("Data validation failed:\n- " + "\n- ".join(errors[:50]))
def write_json(path: Path, payload: Any) -> None:
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + "\n")
def main() -> None:
args = parse_args()
toolgen_root = args.toolgen_root.resolve()
canonical = load_canonical_module(toolgen_root)
eval_path = canonical.EVAL_JSONL
eval_rows = [json.loads(line) for line in eval_path.read_text().splitlines() if line.strip()]
records = build_records(eval_rows, canonical)
aggregates = build_aggregates(records, canonical)
validate(records, aggregates, canonical)
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"schema_version": "1.0.0",
"benchmark": "SearchGen-Bench",
"generated_at": generated_at(args.generated_at),
"dataset": {
"filename": eval_path.name,
"sha256": sha256_file(eval_path),
"n_prompts": len(records),
},
"source_commit": source_commit(toolgen_root),
"scoring": {
"primary_metric": "overall_10",
"source": "augmented_parsed_result_ffjudge_pp.json",
"scale_raw": [0, 3],
"scale_public": [0, 100],
"public_rounding_decimals": 1,
"overall_10_components": list(canonical.COMPONENT_KEYS),
"overall_9_components": OVERALL_9_COMPONENTS,
"overall_9_excluded_component": OVERALL_9_EXCLUDED_COMPONENT,
"missing_policy_default": "zero_fill",
"missing_policy_exceptions": sorted(canonical.SKIP_MISSING_GENS),
},
"partition": {
"NoSearch": 100,
"SearchIntensive": 651,
"VisualSearch": 387,
"TextualSearch": 264,
},
"models": {
model_id: {
"display_name": MODEL_METADATA[model_id][0],
"type": MODEL_METADATA[model_id][1],
"source_id": MODEL_SOURCE_IDS.get(model_id, model_id),
}
for model_id in canonical.TABLE1_GENS
},
"artifacts": [
"prompt_scores.jsonl",
"leaderboard_overall.json",
"leaderboard_by_stratum.json",
"leaderboard_by_domain.json",
"leaderboard_by_failure_mode.json",
],
}
with (output_dir / "prompt_scores.jsonl").open("w") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
write_json(output_dir / "manifest.json", manifest)
write_json(output_dir / "leaderboard_overall.json", aggregates["overall"])
write_json(output_dir / "leaderboard_by_stratum.json", aggregates["strata"])
write_json(output_dir / "leaderboard_by_domain.json", aggregates["domains"])
write_json(output_dir / "leaderboard_by_failure_mode.json", aggregates["failure_modes"])
print(f"Validated and wrote {len(records)} prompts for {len(canonical.TABLE1_GENS)} models to {output_dir}")
if __name__ == "__main__":
main()