arborist/docs/benchmark-matrix.md
russell@unturf.com 69a9642296
docs(#000057): capture known-good serving invocations for the salt buildout
The exact llama.cpp (qwen/4090) and vLLM (hermes/3090) launch commands,
recon'd 2026-05-20, so the foxhop-states salt states can be written
accurately rather than guessed. Notes the convention (salt manages the
systemd unit; engine binaries + model artifacts stay manual on
/mnt/data as documented prereqs) and the live-hermes cutover constraint
(keep >=1 hermes online; qwen is expendable).
2026-05-20 15:20:34 -04:00

199 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# #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`.