# #000057 benchmark test matrix Shareable spec of the control-experiment benchmark — what we measure, how, and the cells in the matrix. Companion to the rolling results in `docs/qa-modes-bench.md` (Addendum 8). ## The question A small language model, asked "who is the president of France?", answers with the *present-day* office-holder ("Emmanuel Macron") even when the grounding corpus is a fixed ~2010 vintage (where the answer is Sarkozy). The model isn't lying — it's reciting its training-data present — but it fails to recognise it should defer to the supplied source, or abstain when it has none. The matrix isolates **what fixes that**: model scale, question framing, retrieval, or reasoning. ## Fixture - **386 office-holder questions** (`bench/qa_questions_stale_map.json`), each paired with a **fixed corpus-vintage gold article** (~2010-2011, verified from the artefact). The gold is the single source of truth a grounded answer must match. - Adversarial by construction: the corpus answer (Sarkozy) differs from the model's training-data present (Macron), so a model reciting its prior is measurably *wrong against the source*. ## Axes | axis | values | what it isolates | |------|--------|------------------| | **model** | `hermes` (Hermes-3-8B, vLLM) · `qwen-nothink` (Qwen3.6-27B, llama.cpp, reasoning off) · `qwen-think` (same, reasoning on) | scale (8B vs 27B) and reasoning | | **framing** | `plain` ("who is the president of France?") · `source_relative` ("According to the reference knowledge base, who is …") · `as_of_corpus` ("As of 2010, who was …") | whether prompt framing alone fixes the drift | | **arm** | `solo` (model alone — measures the parametric prior) · `arborist` (model + retrieval over the 2010 corpus, `answer_mode=claim_lattice`) | whether retrieval fixes it | Full cross product = **3 models × 3 framings × 2 arms = 18 cells**, each over up to 386 items. (The `arborist` arm currently runs `hermes` and `qwen-nothink`; `qwen-think` is measurable but cost-prohibitive — see "Cost dimension".) ## Metrics Every (question, answer, gold) triple gets one verdict from a closed vocabulary: | verdict | meaning | |---------|---------| | **CORRECT_GROUNDED** (CG) | answer matches the gold source | | **WRONG** (W) | gold states something the answer contradicts | | **FABRICATED** (F) | answer asserts specifics absent from gold | | **ABSTAINED** (A) | answer honestly declines ("I don't have that") | | **JUDGE_ERROR** (JE) | judge could not classify deterministically | Two reads of the same verdicts: - **accuracy** — "did it say the corpus answer?" Penalises a model for knowing the *current* office-holder. Reported but flagged as misleading. - **grounding-fidelity** (the defensible read) — "did the model do the right conversational thing: answer when grounded, abstain when not?" W + F are failures (confident ungrounded assertion); A is success on the questions the model can't ground. **CG% and abstain% are the headline numbers.** ## The judge Deterministic, no LLM, no quota: `bench/judge_code.py`. Pipeline (first hit decides): empty/no-gold guard → explicit-abstention regex → short-answer entity-grounding fast path → NLI contradiction (θ=0.85) → lexical verifier (quote/span/entity/paraphrase) → WRONG-vs-FABRICATED tie-break on subject-in-gold. Claim-lattice JSON envelopes are unwrapped to prose before grading so the `arborist` arm grades on equal terms with `solo`. The judge was calibrated against an Opus (SOTA) reference judge on the records Opus could grade: agreement CG 13→47 %, WRONG 56→89 %, ABSTAINED 80→95 %. The deterministic verdict is a *proxy* for grounding (lexical + NLI), not Opus-grade reading; JUDGE_ERROR residue is the natural input to a later LLM-batch pass. NLI runs on a GPU (currently the 4090 `ai` box); the rest is pure Python. ## Cost dimension — for the constraint optimizer (expanded goal) The next-goal matrix scores **quality AND cost** across the serving stack, not just the model. Every cell is a deployable configuration; the optimizer trades grounding-fidelity against energy to pick the config to ship. **Expanded axes (the full cost/quality matrix):** | axis | values | |------|--------| | model | qwen-27B · hermes-8B | | **engine** | **llama.cpp · vLLM** (where the model/engine combo is supported) | | **GPU** | **RTX 3090 · RTX 4090** | | arm | solo · arborist (retrieval) | Cross product = 2 model × 2 engine × 2 GPU × 2 arm = up to **16 quality+cost cells**. Each cell records: | metric | unit | source | |--------|------|--------| | quality (CG% / abstain%) | rate | code judge | | tokens / answer | completion tokens | API usage | | latency / answer | seconds | wall-clock | | **GPU energy** | **watts mean/peak · joules · J/answer · J/token** | `nvidia-smi power.draw` on the inference GPU | | **CPU energy** | **watts mean · joules · J/answer** | Intel RAPL `energy_uj` (package sum) on the box | `bench/watt_bench.py` samples both GPU (`nvidia-smi`) and CPU (RAPL) on the inference box while driving the subset. RAPL `energy_uj` is root-only by default (PLATYPUS / CVE-2020-8694 mitigation) — pass `--cpu-energy-cmd 'sudo cat {path}'` with a sudo rule, or relax the sysfs perm, to capture CPU watts; GPU watts need no special perm. **Serving-stack inventory (recon 2026-05-20):** | box | host | GPU | engine present | model present | |-----|------|-----|----------------|---------------| | 4090 | `ai.foxhop.net:18888` | RTX 4090, i9-14900K (32t) | llama.cpp | qwen-27B gguf | | 3090 | `3090-ai.foxhop.net:18888` | RTX 3090, i9-12900K (24t) | vLLM (venv) | hermes-8B | To fill the matrix, the missing serving configs must be stood up: vLLM+qwen, llama.cpp+hermes (gguf), and each model on the other GPU. That's an infra buildout (install engines, fetch models, manage the single-slot live endpoints), to be captured as **salt states in `~/git/foxhop-states`** (where `ai.foxhop.net`'s `llama-qwen` state already lives; the 3090 box is not yet salt-managed). **Known-good serving invocations (captured 2026-05-20 — the basis for the salt states):** - **qwen / llama.cpp / 4090** (live, salt `llama-qwen/init.sls`): ``` /mnt/data/llm/llama.cpp/build/bin/llama-server \ -m /mnt/data/llm/cache/Qwen3.6-27B-UD-Q4_K_XL.gguf \ --host 0.0.0.0 --port 18888 -ngl 99 -c 65536 -fa on \ --cache-type-k q8_0 --cache-type-v q8_0 -np 1 \ --spec-type draft-mtp --spec-draft-n-max 6 \ --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 1.5 ``` - **hermes / vLLM / 3090** (live, MANUAL tmux — not yet salt; vLLM 0.10.1.1 on python3.12): ``` python -m vllm.entrypoints.openai.api_server \ --model adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic \ --host 0.0.0.0 --port 18888 --max-model-len 82000 \ --enable-auto-tool-choice --tool-call-parser hermes ``` Per the existing convention, salt manages the **systemd unit**; the big artifacts (engine binaries, model files / gguf) live on `/mnt/data` and are provisioned manually, documented as state prereqs. Migrating the live 3090 hermes from its manual tmux session to a salt systemd unit must keep ≥1 hermes online (qwen is the expendable/exploratory workload) — a careful cutover, not a blind highstate. **Why cost matters most here:** reasoning chains are the headline cost finding — `qwen-think` spends 1300-3300 completion tokens/answer vs `qwen-nothink`'s ~50-100 (**20-50× the token cost**), for a workload where `arborist + qwen-nothink` already lands **82 % CG**. And hermes-8B on a 3090 idles at ~127 W vs qwen-27B on a 4090 at ~20 W — idle floor, model size, engine, and GPU tier all move the joules-per-grounded-answer the optimizer cares about. The metric is **grounding-fidelity per joule**, not per answer. ## Results so far (CG%, all arms on the identical calibrated judge) | arm / model | plain | source_relative | as_of_corpus | |-------------|-------|-----------------|--------------| | solo / hermes | 9 % | 5 % | 18 % | | solo / qwen-nothink | 7 % | 0 % | 50 % | | solo / qwen-think | 6 % | 5 % | 44 % | | arborist / hermes (n=40) | 60 % | 62 % | 25 % | | **arborist / qwen-nothink** | **82 %** | **65 %** | 50 % | Headline findings: (1) **retrieval dominates** — no solo config approaches the arborist arms; (2) **reasoning doesn't improve raw correctness** (qwen-think/as_of 44 % vs nothink 50 %) and **breaks honest-abstention** under source_relative framing (nothink abstains 98 %, think only 62 %); (3) **production answer: arborist + qwen-nothink, plain framing, reasoning off — 82 % CG.** The cost axis will confirm whether reasoning's energy premium is ever justified. ## Reproduce ``` # full matrix cell (one model, all framings, both arms) python -m bench.control_sweep --judge code --models qwen-nothink \ --arborist-ref qwen-nothink --n 386 --arborist-n 386 --max-workers 1 # arborist-only (solo data already collected) python -m bench.control_sweep ... --skip-solo # wattage subset (run ON the GPU box) python -m bench.watt_bench --models qwen-nothink --n 20 --gpu-label 4090 ``` Artifacts land in `bench/qa_results/`; scorecards via `bench/score_with_code_judge.py`; judge-vs-judge reconciliation via `bench/analyze_judge_disagreement.py`.