A meta safety-eval framework that assesses the effects of fine-tuning and quantisation on model safety, and evaluates the evaluators to determine if any common LLM-as-a-judge models are biased on safety-critical tasks.
This repository implements the core scaffolding for the Behavioral
Invariance Eval Harness + Meta-Evaluation Infrastructure (PRD v3.1). It targets
consumer hardware (MacBook Pro M5, 32GB) using pre-quantized GGUF checkpoints.
Local models and judges are loaded in-process via llama.cpp (no separate
server, no HTTP hop) — pinned to a specific GGUF quant and loaded sequentially;
remote judges are called through their hosted APIs. Every judge implements the
same Judge.evaluate(...) contract.
Status. This started as AI-generated scaffolding for review. A critical assessment found that several issues would have silently invalidated both halves of the project's goal — most notably, judges sharing weights with the models they scored, and the suite's
expect:PASS/expect:FAILdirective leaking into the LLM judge prompt right next to the instruction to output a verdict. Those have been fixed (see What was fixed). Items still marked [Sam] below are genuinely open decisions: on-hardware verification of the llama.cpp engine settings/memory budgets, ground-truth calibration (no gold-labeled set exists), and the cloud-fallback provider choice.
Pre-quantized checkpoints (HF/Unsloth/bartowski GGUF)
│
▼ hydration: harness/hydrate.py downloads open weights from HF at startup
│
▼ in-process llama.cpp engines (harness/llamacpp_engine.py) — lazy, sequential
│
▼
Test Runner ──> model outputs ──> Judge Panel ──> verdicts + per-item consensus
│ │
│ ▼
│ judges/meta_eval.py
│ (inter-judge kappa,
│ bias by category/
│ technique/severity)
▼
harness/compare.py
(variant vs. base_model_id
behavioral-invariance diff)
Judge panel:
├─ Claude (Anthropic API — trusted, independent of every model under test)
├─ GPT-4o (OpenAI API — trusted, independent of every model under test)
├─ Mistral-7B-Instruct (in-process llama.cpp — deliberately not a model under test)
├─ Llama-3.2-3B-Instruct (in-process llama.cpp — deliberately not a model under test)
└─ Heuristic (deterministic, in-process — refusal directive + real format checks)
No judge shares a checkpoint with any model under test — see Judge/model independence below; this was not true of the original scaffold and is the single most important invariant to preserve when editing either config.
Generation and judging are two separate passes writing to two separate JSON
files (results/model_outputs.json then results/model_verdicts.json — rows
nested under outputs / verdicts), so expensive test-model inference is done
once and can be re-judged — or skipped entirely (see
Generate outputs only). Judging also
writes results/judge_meta_eval.json — the actual judge meta-evaluation (see
Judge meta-evaluation).
Every judge — remote or local — implements the same
Judge.evaluate(item: EvalItem) -> Verdict (judges/items.py; the item carries the request, response, criterion and item_type)
contract, so the panel calls Claude, a local in-process llama.cpp model, or the
heuristic baseline through identical code. Local models and judges that
reference the same GGUF reuse a single in-process engine, and the engine
cache loads models sequentially (one resident at a time by default) so a
test model and the judges never all hold weights at once.
harness/
llamacpp_engine.py # in-process llama.cpp GGUF engines (lazy, sequential, multi-turn, system_prompt)
hydrate.py # download open weights from HuggingFace at startup
model_loader.py # load models UNDER TEST (llama.cpp / Ollama / cloud)
test_suite.py # test-suite schema + JSON loader (cases under `evals`)
test_runner.py # run a suite against models, persist outputs (+ --n-samples)
evaluator.py # CLI: hydrate + generate outputs + run judge panel + meta-eval
compare.py # CLI: behavioral-invariance diff (variant vs. base_model_id)
provenance.py # git commit + suite hash + timestamp stamped into results/*.json
judges/
base.py # Judge ABC + Verdict types; retry/backoff, NonRetryableJudgeError
items.py # EvalItem (the unit a judge grades) + item types
rubric.py # rubric v2: prompts, schemas, StrongREJECT scoring
prompts.py # strip_expect_directive + JSON response parser
claude_judge.py # Anthropic API judge
gpt4_judge.py # OpenAI API judge
local_llamacpp_judge.py # local in-process llama.cpp (GGUF) judge
heuristic_judge.py # deterministic judge: refusal directive + real format-adherence checks
format_checks.py # deterministic JSON/CSV/sentence-count/markdown/python/sections checks
factory.py # build judges from config/judges.yaml
judge_panel.py # orchestrate the panel + naive per-item majority-vote consensus
meta_eval.py # inter-judge Cohen's kappa, bias-by-slice, repeat-sample variance
config/
models.yaml # local + remote model catalogue; base_model_id / quantization lineage
judges.yaml # judge panel definition (priority/enabled); independent of models.yaml
hardware_profile.yaml # llama.cpp engine defaults + resident-model cap
data/
test_suite_v1.json # the 61-case suite (cases under `evals`)
tests/ # unit tests for the pure-Python components
docs/critical-assessment.md # the audit this round of fixes addresses
# 0. Reproducible install (Python 3.14, see .python-version): the exact
# versions the test suite runs against, including llama-cpp-python.
CMAKE_ARGS="-DGGML_METAL=on" pip install -r requirements.lock
# Or install the loose ranges instead:
# 1. Core install (pure-Python wheels; installs cleanly on every platform).
# Includes huggingface_hub, used by the startup weight-hydration step.
pip install -r requirements.txt
# 2. Local model serving.
#
# llama.cpp (loaded IN-PROCESS; required for any `engine: llamacpp` /
# `access: llamacpp` model or judge — no server to start). It builds its own
# bundled llama.cpp at install time; for a Metal build on Apple Silicon:
CMAKE_ARGS="-DGGML_METAL=on" pip install -r requirements-llamacpp.txt
# Ollama is also supported for models under test (over HTTP, auto-managed):
brew install ollama
# 3. API keys for the remote judges (only needed if you run those judges).
export ANTHROPIC_API_KEY=... # Claude judge
export OPENAI_API_KEY=... # GPT-4o judge
# 4. (optional) HuggingFace token for gated repos that hydration downloads.
# Picked up automatically by huggingface_hub. None of the checkpoints
# shipped in config/models.yaml / config/judges.yaml are gated (verified
# live against the HuggingFace API) -- only needed if you add one that is.
export HF_TOKEN=...You do not need to pre-ollama pull the models — when a test model uses the
Ollama backend, harness/model_loader.py starts the ollama serve daemon (if
it isn't already up) and pulls the model tag on demand. Likewise you do not
need to pre-download HF weights: the harness hydrates them at startup (below).
The entrypoint is harness/evaluator.py. Model ids come from
config/models.yaml (e.g. gemma-4-12b, qwen3.5-9b); judge subsets come
from config/judges.yaml.
# In-process llama.cpp test model — nothing to start by hand; weights are
# hydrated, then loaded in-process (sequentially) on first use:
python harness/evaluator.py --models gemma-4-12b --judges all
# Ollama-backed test model (for a model whose serving.engine is `ollama`):
python harness/evaluator.py --models gemma-4-12b --judges all --prefer-engine ollama--judges selects a preset: all (every enabled judge), cheap (priority ≤ 1
— the remote API judges plus the free deterministic heuristic; no local GGUF
loads), or pilot. --judge-ids a,b picks judges by id instead.
--n-samples N (default 3) draws N independent generations per (model,
test); sample i uses seed --seed + i, recorded on every row. A single
non-deterministic draw
otherwise turns "did the model pass" into a noisy one-shot estimate with no
uncertainty measure. Once judged, judges.meta_eval.sample_variance (folded
into results/judge_meta_eval.json automatically when --n-samples > 1)
reports the per-test pass rate and standard error across the samples.
On startup the harness hydrates: it downloads the open-weight checkpoints
this run needs from HuggingFace (the requested --models plus the local judges
actually selected — see Weight hydration). Pass
--no-hydrate to skip it entirely.
python harness/evaluator.py --outputs results/model_outputs.json --judges cheapAn outputs file from before the current schema (no provenance,
sample_index or turns, such as the 2026-08 results/model_outputs.json) is
refused with exit code 2: its system prompts were dropped and its multi-turn
cases collapsed. Regenerate it, or pass --allow-legacy to judge it anyway.
A generation run is checkpointed as it goes; continue a crashed run with
--resume, and replace a finished one only with --overwrite.
To run the eval questions through a model without invoking the judge panel,
pass --no-judge:
python harness/evaluator.py --models gemma-4-12b --no-judgeThis runs the suite against the model and writes only
results/model_outputs.json — a {"outputs": [...], "provenance": {...}}
object, one row per test/model/sample (prompt, category, domain, technique,
severity, criteria, raw model output — or the full turn-by-turn transcript
for a multi-turn case, sample_index, and latency_s generation time). No
judge is built, so no API keys are required. --no-judge requires
--models (there is nothing to generate otherwise).
Results of a full run are written to results/model_verdicts.json — a
{"verdicts": [...], "provenance": {...}} object, one row per test/model with
each judge's verdict plus a naive per-item majority-vote consensus — and to
results/judge_meta_eval.json, the actual cross-item meta-evaluation (see
Judge meta-evaluation). Every results/*.json file
also carries a provenance block: harness git commit, suite file path +
content hash, generation timestamp, and the judge panel/models involved — so a
file found later is self-describing (see harness/provenance.py).
No judge in config/judges.yaml may share a checkpoint (id or model/
checkpoint) with any entry in config/models.yaml. A judge that shares
weights with a model it's scoring is grading itself, not evaluating it, and
silently invalidates the judge-bias analysis this project exists to produce.
The two remote judges (Claude, GPT-4o) are the trusted, structurally-
independent anchor; the two local judges (Mistral-7B-Instruct, Llama-3.2-3B-
Instruct, both via bartowski's GGUF mirrors) were deliberately chosen to be
checkpoints that never appear in config/models.yaml.
judges/meta_eval.py is the actual meta-evaluation — judges.judge_panel. aggregate_verdicts is only ever a naive per-item majority vote. Run
automatically at the end of run_evaluation (writes
results/judge_meta_eval.json; pass --meta-eval "" to skip, or
--reference-judge <id> to add each judge's divergence from one judge you
trust — there is no default; the keyword heuristic's refusal verdicts are
advisory only and never vote). It reports, per batch of judged rows:
- Pairwise inter-judge agreement + Cohen's kappa — raw agreement % is inflated when judges are individually skewed toward one label; kappa corrects for the agreement expected by chance alone.
- Per-category / per-technique / per-severity verdict distribution per judge — surfaces a judge that's systematically softer or harsher on one slice of the suite, which a single overall pass rate would average away.
- Divergence from a reference judge — how much each other judge disagrees with the reference, specifically.
- Repeat-sample variance (only present when
--n-samples> 1 was used) — per-test pass rate + standard error across the repeated draws.
A verdict whose judge call itself failed (error set — see Judge.evaluate's
retry handling below) is excluded from every one of these statistics: counting
a transient API failure as a genuine "AMBIGUOUS" would quietly bias agreement/
bias numbers toward whichever judge happened to have a flaky day.
Nothing computes the diff the harness is named for automatically —
harness/evaluator.py only ever produces independent per-model verdicts.
harness/compare.py closes that gap: given one or more already-judged
results/model_verdicts.json-shaped files and the base_model_id linkage in
config/models.yaml, it reports overall and per-category/technique/severity
pass-rate deltas between a fine-tuned/quantized variant and the base it's
linked to, plus which individual test ids flipped verdict:
python harness/compare.py --verdicts results/model_verdicts.json
python harness/compare.py --verdicts results/model_verdicts.json \
--pairs gemma-4-12B-agentic-fable5:gemma-4-12bOnly pairs where base_model_id is set (and both sides have actually been
judged) are compared; config/models.yaml's header comment explains when to
leave it null instead of guessing. If either side used --n-samples > 1,
repeated samples of the same test are collapsed to one verdict via majority
vote before comparing (see harness.compare.index_by_model) rather than
silently keeping whichever sample happened to load last.
At startup the harness downloads the open weights this run needs from HuggingFace so the first in-process llama.cpp load doesn't block mid-run on a multi-gigabyte download. Hydration is scoped to the run:
- the models named in
--models(nothing when you only re-judge), and - the local (
access: llamacpp) judges that will actually be built — i.e. after the--judgespreset andenabledare applied, and skipped entirely under--no-judge.
So --outputs ... --judges cheap (no --models) downloads nothing for the
models under test (cheap also excludes the local judges, so nothing for
those either); --models gemma-4-12b --judges cheap pulls only the Gemma
checkpoint. Cloud/API models (Anthropic, OpenAI, Replicate) are always skipped.
Downloads are idempotent (llama.cpp reuses the HF cache), so after the first run
hydration only verifies the cache. Skip it with --no-hydrate, or run it
standalone (no scoping filters → downloads everything in the configs):
python harness/hydrate.py # download all open weights now
python harness/hydrate.py --models gemma-4-12b # just this model's weights
python harness/hydrate.py --dry-run # list what would be downloadedHydration downloads through llama-cpp-python — the same
Llama.from_pretrained path used to serve — so it fetches exactly the file the
engine will load, not the whole repo, with no direct huggingface_hub call of
our own. Gated repos need HF_TOKEN set (none of the checkpoints shipped in
config/models.yaml / config/judges.yaml currently are — verified live
against the HuggingFace API). Prefer the exact GGUF file name in
gguf_file (as in HuggingFace's "Use this model" snippet), e.g.
gguf_file: "Mistral-7B-Instruct-v0.3-Q4_K_M.gguf": it's unambiguous and
fails loudly if the name is wrong (hydrate_weights raises, aborting the
run before any generation happens — worth confirming a new entry resolves via
python harness/hydrate.py --dry-run and the HuggingFace API before relying
on it). A glob like "*Q4_K_M.gguf" also works. additional_files fetches
extra parts (split-GGUF shards).
Local models and judges are served in-process via llama.cpp
(harness/llamacpp_engine.py). It is GGUF-native, so each engine is pinned to a
specific GGUF file in a HuggingFace repo via serving.gguf_file (models) /
gguf_file (judges), passed straight to Llama.from_pretrained(repo_id, filename=...) — normally the exact file name (a glob is also accepted). Install
with pip install llama-cpp-python (it builds its own bundled llama.cpp; it does
not reuse a system install).
Sequential loading. The engine cache holds at most llamacpp.max_resident
models in memory at once (config/hardware_profile.yaml, default 1). Loading
a new model frees the least-recently-used one (Llama.close()); an evicted
engine transparently reloads on next use. So a test model and the judges never
all hold weights simultaneously. Raise max_resident to keep both local judges
resident if the memory budget allows.
To keep this cheap, the judge panel evaluates judge-outer (JudgePanel. evaluate_batch): every judge scores the whole batch of outputs before the next
judge runs, so each local judge loads once per run rather than reloading for each
row.
# config/models.yaml
local_models:
- id: "mistral-7b-instruct-4bit"
checkpoint: "bartowski/Mistral-7B-Instruct-v0.3-GGUF" # HF repo -- verify it
base_model_id: null # or another entry's id, if this is a fine-tune of it
serving:
engine: "llamacpp" # llamacpp (in-process GGUF) | ollama
gguf_file: "Mistral-7B-Instruct-v0.3-Q4_K_M.gguf" # the exact quant to load
mode: "chat" # chat (default; instruct models) | completion (base)
ollama_tag: "mistral:7b" # only used if engine: ollamaChat vs completion. serving.mode: chat (the default) sends each test
prompt (plus a real system role message when the test case sets
system_prompt) through the GGUF's embedded chat template
(create_chat_completion) — required for instruction-tuned checkpoints (-it
/ -instruct), which continue a raw prompt instead of answering it. Set
mode: "completion" for base (non-instruct) checkpoints. Local judges always
use the chat path, with decoding additionally grammar-constrained to valid
JSON. A multi-turn test case (turns: [...] in the suite instead of prompt)
goes through chat_turns/generate_turns — each reply is fed back as an
assistant turn before the next turns entry is sent, so the model actually
sees (and can be held to) its own prior answer.
A model whose serving.engine is ollama is generated via the local Ollama
daemon over HTTP instead. model_loader.py starts ollama serve (if down) and
pulls the ollama_tag on first use, so there is nothing to launch by hand. The
--prefer-engine {llamacpp,ollama} flag only sets the fallback for entries that
don't name a serving.engine; a model that pins its engine wins. The judge panel
is llama.cpp only — there is no Ollama judge backend.
No. Both local backends are self-managing:
| Backend | Startup |
|---|---|
| in-process llama.cpp | Automatic. harness/llamacpp_engine.py loads the GGUF in-process on first generate (and unloads it when another model needs the slot). Nothing to launch. |
| Ollama | Automatic. model_loader.py starts the ollama serve daemon (if down) and pulls the tag on first use. |
pytestThe tests cover the deterministic, network-free parts: prompt formatting +
JSON parsing (including that the expect:PASS/expect:FAIL directive never
reaches the rendered judge prompt), verdict normalisation, base-class error
handling and retry/backoff, the heuristic judge's real format-adherence
checks, the suite loader (including multi-turn turns and system_prompt),
the judge factory, panel aggregation, harness/compare.py's behavioral-
invariance diff, and judges/meta_eval.py's inter-judge agreement/bias
statistics.
See docs/critical-assessment.md for the full
audit. In summary, fixed: the judge/model self-judging confound (judges no
longer share any checkpoint with a model under test); the expect:PASS/
expect:FAIL directive leaking into the LLM judge prompt; system_prompt
being silently dropped; multi-turn cases collapsing into one static prompt
instead of a real conversation; the heuristic judge's directive regex never
matching the suite's actual expect:PASS/expect:FAIL vocabulary; the
absence of any real meta-evaluation (judges/meta_eval.py now computes
inter-judge Cohen's kappa and per-slice bias); the absence of any fine-tune/
quantization lineage (base_model_id + harness/compare.py); --judges cheap being the most expensive preset instead of the cheapest; and the lack
of retry/backoff, error-vs-genuine-ambiguous distinction, repeat-sampling
variance, and result provenance.
- On-hardware verification. Confirm the in-process llama.cpp engine settings
in
config/hardware_profile.yaml(n_ctx,n_gpu_layers,n_batch), thellamacpp.max_residentcap, the 32GB memory budget, and load time on the actual M5 (PRD "Sam Must Review" checklist). - Cloud fallback wiring.
harness.model_loader.ReplicateModel.generateraisesNotImplementedErrorpending a provider + token choice. - Ground-truth calibration. There is no human-labeled gold set anywhere in
the repo, so no judge's accuracy (as opposed to inter-judge agreement) can
be computed —
judges/meta_eval.pyreports agreement/bias, not accuracy. - Unconfirmed
base_model_idlineage. A fewfine_tuned_modelsentries inconfig/models.yamlarebase_model_id: nullwith a comment explaining why (a merge with no single base, an ambiguous size-class name) — verify against the model card before filling these in rather than guessing.