arkai2025's picture
feat(tools): same-model overwrite on eval merge
d4cc485
Raw
History Blame Contribute Delete
7.2 kB
"""Voxel model-eval harness — one CLI for the box-layout generation test.
Processes ONE model at a time (download -> test -> optionally delete its
download -> next), so disk peak is a single model. Results merge into
report_data.js after each model, so partial progress is never lost. Grammar
enforcement (response_format json_object + schema) is always on — without it you
measure prompt-following noise, not capability (see wiki/model-selection-spike.md).
Requires llama-cpp-python, which lives in the sibling venv, NOT this repo:
PYTHON_BIN=/Users/chenzhikai/Documents/Project/build-a-buddy-hf/.venv/bin/python
$PYTHON_BIN tools/voxel-model-eval/eval.py --delete-after
`llama_cpp` and `huggingface_hub` are imported lazily inside functions so the
repo's pytest suite (which only touches parse_models_arg / core) does not need
the sibling venv.
"""
from __future__ import annotations
import argparse
import json
import shutil
import time
from pathlib import Path
import core
from models import MODELS
OUT = Path(__file__).resolve().parent
HUB = Path.home() / ".cache/huggingface/hub"
DATA_FILE = OUT / "report_data.js"
FEWSHOT_FILE = OUT / "report_data_fewshot.js"
def parse_models_arg(s: str):
"""'label=repo,label=repo' -> [(label, repo), ...]. Empty -> []. Whitespace-tolerant."""
out = []
for chunk in s.split(","):
chunk = chunk.strip()
if not chunk:
continue
label, _, repo = chunk.partition("=")
out.append((label.strip(), repo.strip()))
return out
def pick_q4(repo: str):
"""Resolve the Q4_K_M GGUF filename in a repo (falls back to the first gguf)."""
from huggingface_hub import list_repo_files
fs = [f for f in list_repo_files(repo) if f.lower().endswith(".gguf")]
for f in fs:
if "q4_k_m" in f.lower():
return f
return fs[0] if fs else None
def resolve_or_download(repo: str):
"""Return a local path to the repo's Q4_K_M GGUF, downloading if absent."""
from huggingface_hub import hf_hub_download
fname = pick_q4(repo)
if not fname:
return None
return hf_hub_download(repo, fname)
def load_existing(path: Path):
"""Parse the window.SPIKE_DATA array out of an existing report data file."""
if not path.exists():
return []
txt = path.read_text(encoding="utf-8").strip()
s, e = txt.find("["), txt.rfind("]")
return json.loads(txt[s:e + 1]) if s >= 0 else []
def write_data(path: Path, rows):
path.write_text(
"window.SPIKE_DATA = " + json.dumps(rows, ensure_ascii=False) + ";\n",
encoding="utf-8",
)
def merge_rows(existing, new):
"""Merge `new` result rows into `existing`, with same-model OVERWRITE:
any existing rows whose `model` label is being re-tested are dropped, so a
re-run updates a model in place instead of duplicating it. Rows for models
not in `new` are kept; brand-new models append. Order: kept, then new.
"""
new_models = {r["model"] for r in new}
kept = [r for r in existing if r.get("model") not in new_models]
return kept + new
def run_model(llm, label: str, themes, sys_prompt: str):
"""Run one loaded model across the themes; return result rows."""
rows = []
for tid, theme in themes:
t1 = time.time()
try:
out = llm.create_chat_completion(
messages=[{"role": "system", "content": sys_prompt},
{"role": "user", "content": theme}],
max_tokens=1500, temperature=0.8, top_p=0.95,
response_format={"type": "json_object", "schema": core.BOX_SCHEMA},
)
text = out["choices"][0]["message"]["content"]
ctoks = out.get("usage", {}).get("completion_tokens", 0)
except Exception as e:
text, ctoks = f"__ERR__ {e}", 0
gen_s = time.time() - t1
obj = core.extract_json(text)
boxes = obj.get("boxes", []) if isinstance(obj, dict) else []
boxes = [b for b in boxes if isinstance(b, dict) and all(k in b for k in ("x", "y", "z", "w", "h", "d"))]
rows.append({"model": label, "prompt": tid,
"name": (obj.get("name") if isinstance(obj, dict) else None) or "(unnamed)",
"boxes": boxes, "json_ok": bool(boxes), "gen_s": round(gen_s, 1)})
print(f" [{label}/{tid}] {gen_s:.1f}s tok={ctoks} json={bool(boxes)} boxes={len(boxes)}", flush=True)
return rows
def main():
ap = argparse.ArgumentParser(description="Voxel box-layout model eval (grammar-enforced).")
ap.add_argument("--models", default="", help="'label=repo,...' override for models.py")
ap.add_argument("--delete-after", action="store_true", help="remove each model's HF cache dir after testing")
ap.add_argument("--fewshot", action="store_true", help="BASE-vs-REF exemplar A/B instead of the 8-theme sweep")
ap.add_argument("--fresh", action="store_true", help="overwrite the data file instead of merging")
args = ap.parse_args()
from llama_cpp import Llama # lazy: only needed for an actual run
model_list = parse_models_arg(args.models) if args.models else list(MODELS)
if not model_list:
print("No models to test. Edit models.py or pass --models 'label=repo'.", flush=True)
return
if args.fewshot:
import exemplars
out_file = FEWSHOT_FILE
conditions = [("BASE", core.SYS), ("REF", core.SYS + exemplars.REF_NOTE)]
themes = exemplars.FEWSHOT_THEMES
else:
out_file = DATA_FILE
conditions = [("", core.SYS)]
themes = core.THEMES
if args.fresh and out_file.exists():
out_file.unlink()
for label, repo in model_list:
print(f"\n=== {label} ({repo}) ===", flush=True)
try:
path = resolve_or_download(repo)
except Exception as e:
print(f" DOWNLOAD FAIL: {e}", flush=True); continue
if not path:
print(f" SKIP: no gguf in {repo}", flush=True); continue
rows = []
try:
llm = Llama(model_path=path, n_ctx=4096, n_gpu_layers=-1, verbose=False)
for cond, sys_prompt in conditions:
run_label = f"{label}-{cond}" if cond else label
rows += run_model(llm, run_label, themes, sys_prompt)
del llm
except Exception as e:
print(f" LOAD/RUN FAIL: {e}", flush=True)
if rows:
existing = load_existing(out_file)
merged = merge_rows(existing, rows)
replaced = len(existing) + len(rows) - len(merged)
write_data(out_file, merged)
note = f" (replaced {replaced} stale)" if replaced else ""
print(f" merged +{len(rows)} -> {len(merged)} total in {out_file.name}{note}", flush=True)
if args.delete_after:
cache_dir = HUB / f"models--{repo.replace('/', '--')}"
if cache_dir.exists():
shutil.rmtree(cache_dir, ignore_errors=True)
print(f" reclaimed disk: removed {cache_dir.name}", flush=True)
print("\n=== EVAL RUN DONE ===", flush=True)
if __name__ == "__main__":
main()