| """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 |
|
|
| 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() |
|
|