Skip to content

Commit 5a36bff

Browse files
committed
feat(evals): inspect-ai harness + promptfoo merge gate + bench-off scaffold
Workstream C (research-implementation program 2026-07-08): - inspect-ai==0.3.244 (PR #11 anchor) uv project; benchoff task (6-sample plumbing-scale set — grow before treating deltas as adoption evidence); .eval transcripts canonical, export_summary.py renders public/data JSON. Live-verified through the gateway (2 samples, accuracy 1.0). Gotcha documented in-code: use openai-api/llm/... — the plain openai provider speaks the Responses API, which upstream lanes 404. - promptfoo eval-gate workflow (SHA-pinned action + checkout): threshold 80, repeat 2, pass-1-of-2, disk cache; scoped to evals/** PRs; self-skips with a warning until the UNSIGNED_LLM_BENCH_KEY secret exists (deploy-token gate pattern). - Prereqs documented: paas #880 mints the capped bench key + MiniMax lane.
1 parent f700e9f commit 5a36bff

8 files changed

Lines changed: 2745 additions & 0 deletions

File tree

.github/workflows/eval-gate.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Merge gate for eval-definition changes (gap-closure verdict 2026-07-07:
2+
# promptfoo Action = the OSS merge gate; Inspect AI stays the deep-eval layer).
3+
# Scoped to evals/** so dashboard PRs never burn tokens. Uses the capped bench
4+
# key (OPS-465 unattended lane) — NEVER the shared interactive key.
5+
name: eval-gate
6+
7+
on:
8+
pull_request:
9+
paths:
10+
- "evals/**"
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write # PR summary comment
15+
16+
jobs:
17+
promptfoo:
18+
runs-on: ubuntu-latest
19+
# Skip cleanly until the operator adds the bench key secret (mirrors the
20+
# deploy workflow's token gate pattern).
21+
steps:
22+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
23+
- name: bench key present?
24+
id: key
25+
run: |
26+
if [ -n "${{ secrets.UNSIGNED_LLM_BENCH_KEY }}" ]; then
27+
echo "present=true" >> "$GITHUB_OUTPUT"
28+
else
29+
echo "::warning::UNSIGNED_LLM_BENCH_KEY secret not set — eval gate skipped"
30+
echo "present=false" >> "$GITHUB_OUTPUT"
31+
fi
32+
- name: promptfoo eval
33+
if: steps.key.outputs.present == 'true'
34+
uses: promptfoo/promptfoo-action@04839e664f52212b877170219dab0d7ad2bf6440 # v1.3
35+
with:
36+
config: evals/promptfooconfig.yaml
37+
github-token: ${{ secrets.GITHUB_TOKEN }}
38+
# Flakiness handling per the verified pattern: pass-k-of-n tolerates
39+
# grader/model variance; consistent failures still gate.
40+
fail-on-threshold: "80"
41+
repeat: "2"
42+
repeat-min-pass: "1"
43+
# Default disk cache; prune stale entries in CI.
44+
cache-path: ~/.promptfoo/cache
45+
env:
46+
UNSIGNED_LLM_BENCH_KEY: ${{ secrets.UNSIGNED_LLM_BENCH_KEY }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
.wrangler/
22
node_modules/
3+
4+
# evals
5+
evals/logs/
6+
evals/.venv/

evals/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<!-- lineage
2+
role: eval-harness-readme
3+
conforms_to: ../README.md
4+
consumes: agentic docs/eval/2026-07-07-* (verdicts: inspect-ai anchor, promptfoo gate, bench-off rule)
5+
-->
6+
7+
# evals — inspect-ai harness + promptfoo merge gate
8+
9+
Greenfield lane from the 2026-07-08 research-implementation program. Two
10+
layers, per the gap-closure verdicts:
11+
12+
- **inspect-ai** (`inspect-ai==0.3.244`, the PR #11 anchor) — deep evals.
13+
`.eval` transcripts under `logs/` are the **canonical evidence**;
14+
`export_summary.py` renders them into `public/data/benchoff.json` for the
15+
dashboard. First task: `tasks/benchoff.py` (MiniMax-M2.7 vs GLM-5.2 vs
16+
Kimi-K2.7-Code — a plumbing-scale task-set; grow it before treating deltas
17+
as adoption evidence).
18+
- **promptfoo** (`.github/workflows/eval-gate.yml`, SHA-pinned action) —
19+
merge-blocking gateway regression on `evals/**` PRs: threshold 80, repeat 2,
20+
pass-1-of-2 flake tolerance, disk cache. Self-skips with a warning until the
21+
`UNSIGNED_LLM_BENCH_KEY` secret exists.
22+
23+
## Run the bench-off
24+
25+
```bash
26+
cd evals && uv sync
27+
export LLM_BASE_URL=https://llm.unsigned.gg/v1
28+
export LLM_API_KEY=$UNSIGNED_LLM_BENCH_KEY # capped OPS-465 lane key
29+
# openai-api/<service>/... pins chat-completions; the plain openai provider
30+
# uses the Responses API which upstream lanes 404 (verified 2026-07-08).
31+
uv run inspect eval tasks/benchoff.py \
32+
--model openai-api/llm/zai/GLM-5.2 \
33+
--model openai-api/llm/moonshotai/kimi-k2.7-code \
34+
--model openai-api/llm/minimax/minimax-m2.7
35+
uv run python export_summary.py
36+
```
37+
38+
## Prereqs (operator)
39+
40+
1. paas PR #880 synced (mints the capped `bench` virtual key + adds the
41+
`minimax/minimax-m2.7` lane).
42+
2. Repo secret `UNSIGNED_LLM_BENCH_KEY` = the bench key value (from OpenBao
43+
`secret/llm` property `bench-api-key`).
44+
3. `CLOUDFLARE_API_TOKEN` still pending separately for the deploy job.

evals/export_summary.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Export inspect .eval transcripts into the dashboard's data dir.
2+
3+
Reads every log under evals/logs/, aggregates per-model accuracy + sample
4+
count + token usage, writes public/data/benchoff.json for the existing static
5+
dashboard. The .eval files remain the canonical evidence; this JSON is a view.
6+
7+
uv run python export_summary.py [--logs logs] [--out ../public/data/benchoff.json]
8+
"""
9+
10+
import argparse
11+
import json
12+
from pathlib import Path
13+
14+
from inspect_ai.log import list_eval_logs, read_eval_log
15+
16+
17+
def main() -> None:
18+
ap = argparse.ArgumentParser()
19+
ap.add_argument("--logs", default="logs")
20+
ap.add_argument("--out", default="../public/data/benchoff.json")
21+
args = ap.parse_args()
22+
23+
results = {}
24+
for info in list_eval_logs(args.logs):
25+
log = read_eval_log(info, header_only=True)
26+
if log.status != "success" or not log.results:
27+
continue
28+
model = log.eval.model
29+
accuracy = None
30+
for score in log.results.scores:
31+
for name, metric in score.metrics.items():
32+
if name == "accuracy":
33+
accuracy = metric.value
34+
usage = log.stats.model_usage.get(model)
35+
results[model] = {
36+
"task": log.eval.task,
37+
"accuracy": accuracy,
38+
"samples": log.results.total_samples,
39+
"input_tokens": usage.input_tokens if usage else None,
40+
"output_tokens": usage.output_tokens if usage else None,
41+
"run_id": log.eval.run_id,
42+
"created": log.eval.created,
43+
}
44+
45+
out = Path(args.out)
46+
out.parent.mkdir(parents=True, exist_ok=True)
47+
out.write_text(json.dumps({"benchoff": results}, indent=2, sort_keys=True) + "\n")
48+
print(f"wrote {out} ({len(results)} models)")
49+
50+
51+
if __name__ == "__main__":
52+
main()

evals/promptfooconfig.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Gateway regression gate — the merge-blocking promptfoo config.
2+
# Runs on eval-def PRs only (see .github/workflows/eval-gate.yml). Tiny by
3+
# design: a handful of deterministic asserts against two gateway lanes proves
4+
# routing + auth + basic model sanity without burning tokens on every PR.
5+
# Anthropic lanes deliberately excluded (cache-sticky path stays clean).
6+
7+
providers:
8+
- id: openai:chat:zai/GLM-5.2
9+
config:
10+
apiBaseUrl: https://llm.unsigned.gg/v1
11+
apiKeyEnvar: UNSIGNED_LLM_BENCH_KEY
12+
- id: openai:chat:moonshotai/kimi-k2.7-code
13+
config:
14+
apiBaseUrl: https://llm.unsigned.gg/v1
15+
apiKeyEnvar: UNSIGNED_LLM_BENCH_KEY
16+
17+
prompts:
18+
- "Answer with only the requested value, no prose. {{question}}"
19+
20+
tests:
21+
- vars:
22+
question: "What is 17 * 3?"
23+
assert:
24+
- type: contains
25+
value: "51"
26+
- vars:
27+
question: "Name the HTTP status code for Not Found."
28+
assert:
29+
- type: contains
30+
value: "404"
31+
- vars:
32+
question: "Reply with exactly the word: ok"
33+
assert:
34+
- type: icontains
35+
value: "ok"

evals/pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "unsigned-bench-evals"
3+
version = "0.1.0"
4+
description = "inspect-ai eval harness for the unsigned model gateway (bench-off + regression tasks)"
5+
requires-python = ">=3.11"
6+
dependencies = [
7+
# The PR #11 bench anchor — bump deliberately, PR per bump.
8+
"inspect-ai==0.3.244",
9+
"openai>=1.60",
10+
]
11+
12+
[tool.uv]
13+
package = false

evals/tasks/benchoff.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Bench-off task: MiniMax-M2.7 vs GLM-5.2 vs Kimi-K2.7-Code via the gateway.
2+
3+
Scaffold task-set (wave-1, 2026-07-08 program): small, deterministic,
4+
string-verifiable coding samples — enough to exercise the full lane
5+
(gateway -> model -> scorer -> .eval transcript -> dashboard JSON), NOT a
6+
definitive capability ranking. Grow the dataset before treating deltas as
7+
adoption evidence (the survey's rule: verify vendor numbers, never adopt on
8+
marketing claims).
9+
10+
Run (bench key = the capped OPS-465 lane, never the shared interactive key):
11+
12+
export LLM_BASE_URL=https://llm.unsigned.gg/v1
13+
export LLM_API_KEY=$UNSIGNED_LLM_BENCH_KEY
14+
uv run inspect eval tasks/benchoff.py \
15+
--model openai-api/llm/zai/GLM-5.2 \
16+
--model openai-api/llm/moonshotai/kimi-k2.7-code \
17+
--model openai-api/llm/minimax/minimax-m2.7
18+
# NOTE: openai-api/<service>/ (not openai/) — the plain openai provider
19+
# speaks the Responses API, which the gateway's upstream lanes 404
20+
# (verified live 2026-07-08); openai-api pins chat-completions.
21+
22+
.eval transcripts under logs/ are the canonical evidence (model-infra gap
23+
survey decision); export_summary.py turns them into public/data/benchoff.json.
24+
"""
25+
26+
from inspect_ai import Task, task
27+
from inspect_ai.dataset import Sample
28+
from inspect_ai.scorer import includes
29+
from inspect_ai.solver import generate, system_message
30+
31+
SYSTEM = (
32+
"You are a precise coding assistant. Answer with ONLY the requested "
33+
"output — no prose, no markdown fences."
34+
)
35+
36+
SAMPLES = [
37+
Sample(
38+
input="Write a Python one-liner expression (no assignment) that reverses the string s. Reply with only the expression.",
39+
target="s[::-1]",
40+
),
41+
Sample(
42+
input="What does this Python print? `print(sorted({'b': 2, 'a': 1}))` Reply with only the printed output.",
43+
target="['a', 'b']",
44+
),
45+
Sample(
46+
input="Give the exact jq filter to extract the field `id` from every element of a top-level JSON array. Reply with only the filter.",
47+
target=".[].id",
48+
),
49+
Sample(
50+
input="In git, what single flag makes `git log` show one commit per line? Reply with only the flag.",
51+
target="--oneline",
52+
),
53+
Sample(
54+
input="What is the exit status of `bash -c 'false || true'`? Reply with only the number.",
55+
target="0",
56+
),
57+
Sample(
58+
input="Complete: a Kubernetes CronJob field that prevents overlapping runs is `concurrencyPolicy: ___`. Reply with only the value.",
59+
target="Forbid",
60+
),
61+
]
62+
63+
64+
@task
65+
def benchoff() -> Task:
66+
return Task(
67+
dataset=SAMPLES,
68+
solver=[system_message(SYSTEM), generate()],
69+
scorer=includes(),
70+
)

0 commit comments

Comments
 (0)