judge_for exists because a model scoring its own prose is a known bias and the default judge is in the evaluated set. It fell back to a single hardcoded ALTERNATE_JUDGE = "qwen3.6-35b" -- which is also in that set, so with `--judge-model qwen3.6-35b` the guard fired on qwen3.6-35b and returned qwen3.6-35b. The one case it exists to prevent was the one case it produced. It degraded silently, which is the part that matters: a self-graded score looks exactly like any other judge output, so nothing in the run would say so. Now it tries the configured judge first and then a list of alternates, taking the first from a different family, and returns None when every candidate shares the model's family -- the caller skips the task rather than recording a self-graded sample. That is the same rule score_judge already applies to a judge reply it cannot parse: no sample beats a false one. Matching stays on the family via parse_base_model_id, so a -fast row is not judged by its reasoning-on sibling either. Same weights, same conflict. Also renames the judge-task COUNT computed before the loop, which shared the name `judged` with the per-task judge result inside it. Harmless today only because the count is consumed before the loop starts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WSkcSD2Jzkxo1Kw27ecfXJ
579 lines
22 KiB
Python
579 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Run the self-eval task set against models and write ``proficiency``.
|
|
|
|
Why this exists: ``proficiency_score`` is the ONLY category-dependent term in
|
|
the composite, so until this table has data, ``task_category`` cannot change a
|
|
routing decision at all — the classifier's category output is computed, paid
|
|
for, and then discarded. This is what makes it matter.
|
|
|
|
Scoring is objective wherever the category admits it (see evals/tasks.yaml):
|
|
code is executed against checks, exact answers are compared, tool calls are
|
|
inspected structurally. Only the four prose categories fall back to a judge.
|
|
|
|
Scores accumulate rather than replace (``proficiency_store.add_self_eval``),
|
|
so running this repeatedly tightens estimates and pushes categories past
|
|
``self_eval_min_samples`` into a proper blend with the leaderboard prior.
|
|
|
|
python eval_proficiency.py --dry-run # plan only, no calls
|
|
python eval_proficiency.py # every identity, every task
|
|
python eval_proficiency.py --models kimi-k3 --categories coding_general
|
|
|
|
SAFETY: `code` tasks execute model-generated Python. Isolation is a
|
|
subprocess with a wall-clock timeout, running in a temp directory — not a
|
|
container and not a real sandbox. The task prompts ask for small pure
|
|
functions, so nothing invites filesystem or network use, but treat this as
|
|
"bounded", not "safe against a hostile model".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import requests
|
|
import yaml
|
|
|
|
from config import RouterConfig, load_config
|
|
from proficiency_store import add_self_eval, propagate_to_variants
|
|
|
|
TASKS_PATH = "evals/tasks.yaml"
|
|
CODE_TIMEOUT_SECONDS = 15
|
|
CALL_TIMEOUT_SECONDS = 300
|
|
|
|
# Generous on purpose. These are reasoning models and the trace is billed
|
|
# against the same budget as the answer: at 1200, qwen3.6-35b spent ~4,200
|
|
# characters thinking and returned an EMPTY content field, scoring 0.00 on
|
|
# tasks it can plainly do. The cap must clear the trace, not just the answer,
|
|
# and the harder task set makes traces longer still.
|
|
#
|
|
# Clamped per model at request time: this exceeds gemma-4-31b's advertised
|
|
# 16384 output limit, and asking for more than a model allows is an error
|
|
# rather than a silent truncation.
|
|
EVAL_MAX_TOKENS = 24000
|
|
|
|
# Preamble for the sandbox: common imports so ordinary solutions run, plus the
|
|
# `raises` helper the checks use. Defined AFTER the model's code so a model
|
|
# that shadows one of these does not break the harness.
|
|
HARNESS_PREAMBLE = "import collections, itertools, json, math, re, string, time\n"
|
|
HARNESS_HELPERS = '''
|
|
def raises(exc, fn, *a, **kw):
|
|
try:
|
|
fn(*a, **kw)
|
|
except exc:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
return False
|
|
'''
|
|
|
|
FENCE_RE = re.compile(r"^\s*```[a-zA-Z]*\n(.*?)```", re.DOTALL | re.MULTILINE)
|
|
|
|
|
|
# --- extraction and scoring ----------------------------------------------
|
|
|
|
def strip_fences(text: str) -> str:
|
|
"""Pull code out of a markdown block if the model added one anyway.
|
|
|
|
Every code prompt says "no markdown fences" and models add them regularly,
|
|
so this is normalization rather than leniency — a model that solved the
|
|
task shouldn't score zero for formatting.
|
|
"""
|
|
match = FENCE_RE.search(text or "")
|
|
code = match.group(1) if match else (text or "")
|
|
# Strip the WHOLE reply's leading/trailing whitespace: a single leading
|
|
# space turns an otherwise perfect function into IndentationError, which
|
|
# is how kimi-k2.7-code scored 0.00 on every coding task in an early run.
|
|
# Internal indentation is untouched.
|
|
return code.strip()
|
|
|
|
|
|
def parse_check_results(stdout: str, marker: str) -> dict[int, bool]:
|
|
"""Read the harness's own verdicts out of a script's stdout.
|
|
|
|
Keyed by check INDEX rather than counted, which bounds the total at the
|
|
number of checks even if a line somehow repeats, and drops anything that
|
|
is not exactly one of the harness's own three-token lines.
|
|
"""
|
|
results: dict[int, bool] = {}
|
|
for line in stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) != 3 or parts[0] != marker or parts[2] not in ("PASS", "FAIL"):
|
|
continue
|
|
try:
|
|
index = int(parts[1])
|
|
except ValueError:
|
|
continue
|
|
results[index] = parts[2] == "PASS"
|
|
return results
|
|
|
|
|
|
def score_code(model_output: str, checks: list[str]) -> tuple[float, str]:
|
|
"""Execute the model's code and eval each check against it.
|
|
|
|
Returns (fraction of checks passing, detail). Partial credit is
|
|
deliberate: a function correct on three of four cases is genuinely better
|
|
than one that fails everything, and a binary score throws that away.
|
|
|
|
The verdict lines carry a per-run nonce because this harness runs the
|
|
model's code as ``__main__``, and models routinely append a demo block. One
|
|
that prints the word PASS -- `print("self-test:", f(2) == 4 and "PASS")` --
|
|
was counted as a passing check by the old substring count, and a two-check
|
|
task scored 1.50. The model cannot predict the nonce, so it cannot vote on
|
|
its own work. That was the fourth harness bug here to score the rig rather
|
|
than the model; this one inflated instead of deflating, which is why it
|
|
went unnoticed.
|
|
"""
|
|
if not checks:
|
|
return 0.0, "no checks"
|
|
|
|
code = strip_fences(model_output)
|
|
marker = f"CHECK-{secrets.token_hex(8)}"
|
|
harness = (
|
|
HARNESS_PREAMBLE
|
|
+ code
|
|
+ "\n"
|
|
+ HARNESS_HELPERS
|
|
+ f"\nCHECKS = {checks!r}\n"
|
|
+ "for _i, _c in enumerate(CHECKS):\n"
|
|
+ " try:\n"
|
|
+ " _ok = bool(eval(_c))\n"
|
|
+ " except Exception:\n"
|
|
+ " _ok = False\n"
|
|
+ f" print({marker!r}, _i, 'PASS' if _ok else 'FAIL')\n"
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script = Path(tmp) / "harness.py"
|
|
script.write_text(harness)
|
|
try:
|
|
proc = subprocess.run(
|
|
[sys.executable, str(script)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=CODE_TIMEOUT_SECONDS,
|
|
cwd=tmp,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return 0.0, "timeout"
|
|
|
|
results = parse_check_results(proc.stdout, marker)
|
|
passed = sum(1 for i in range(len(checks)) if results.get(i))
|
|
if passed == 0 and proc.returncode != 0:
|
|
# Distinguish "wrote broken code" from "wrote code that fails cases"
|
|
first_line = (proc.stderr or "").strip().splitlines()[-1:] or [""]
|
|
return 0.0, f"did not run: {first_line[0][:80]}"
|
|
return passed / len(checks), f"{passed}/{len(checks)} checks"
|
|
|
|
|
|
def normalize_answer(text: str) -> str:
|
|
"""Reduce a free-text reply to a comparable token.
|
|
|
|
Models wrap a number in prose, punctuation, or thousands separators even
|
|
when told not to; none of that is the thing being measured.
|
|
"""
|
|
cleaned = (text or "").strip().replace(",", "")
|
|
numbers = re.findall(r"-?\d+(?:\.\d+)?", cleaned)
|
|
if numbers:
|
|
value = numbers[-1] # the conclusion, if it reasoned out loud
|
|
return value.rstrip("0").rstrip(".") if "." in value else value
|
|
return cleaned.lower().strip(" .!\"'")
|
|
|
|
|
|
def score_exact(model_output: str, expected: str) -> tuple[float, str]:
|
|
got = normalize_answer(model_output)
|
|
want = normalize_answer(expected)
|
|
return (1.0, f"{got!r}") if got == want else (0.0, f"got {got!r} want {want!r}")
|
|
|
|
|
|
def score_tool(tool_calls: list, task: dict) -> tuple[float, str]:
|
|
"""Score a tool-use task structurally — no judge required.
|
|
|
|
Three things are worth distinguishing and each is a task in the set:
|
|
calling the right tool, choosing correctly between several, and NOT
|
|
calling a tool when none applies. The last is scored because a model that
|
|
reaches for a tool on every prompt is a real failure mode in an agent
|
|
loop.
|
|
"""
|
|
expected_tool = task.get("expect_tool")
|
|
|
|
if expected_tool is None:
|
|
return (1.0, "correctly abstained") if not tool_calls else (
|
|
0.0,
|
|
f"called {tool_calls[0]['function']['name']} when none applied",
|
|
)
|
|
|
|
if not tool_calls:
|
|
return 0.0, "no tool call"
|
|
|
|
called = tool_calls[0]["function"]["name"]
|
|
if called != expected_tool:
|
|
return 0.0, f"called {called}, wanted {expected_tool}"
|
|
|
|
# Right tool. Give partial credit and check the arguments that matter.
|
|
expected_args = task.get("expect_args") or {}
|
|
if not expected_args:
|
|
return 1.0, "correct tool"
|
|
try:
|
|
got_args = json.loads(tool_calls[0]["function"].get("arguments") or "{}")
|
|
except json.JSONDecodeError:
|
|
return 0.5, "correct tool, unparseable arguments"
|
|
|
|
matched = 0
|
|
for key, want in expected_args.items():
|
|
got = got_args.get(key)
|
|
if isinstance(want, str) and isinstance(got, str):
|
|
ok = want.lower() in got.lower()
|
|
else:
|
|
ok = str(got) == str(want)
|
|
matched += bool(ok)
|
|
# Floor at 0.5 for picking the right tool; arguments carry the rest.
|
|
return 0.5 + 0.5 * (matched / len(expected_args)), f"correct tool, {matched}/{len(expected_args)} args"
|
|
|
|
|
|
# --- provider calls -------------------------------------------------------
|
|
|
|
def call_model(
|
|
base_url: str,
|
|
api_key: str,
|
|
model_id: str,
|
|
task: dict,
|
|
max_output_tokens: Optional[int] = None,
|
|
) -> tuple[str, list, bool]:
|
|
"""One completion. Returns (text, tool_calls, truncated)."""
|
|
budget = EVAL_MAX_TOKENS
|
|
if max_output_tokens:
|
|
budget = min(budget, max_output_tokens)
|
|
body = {
|
|
"model": model_id,
|
|
"messages": [{"role": "user", "content": task["prompt"]}],
|
|
"temperature": 0,
|
|
"max_tokens": budget,
|
|
}
|
|
if task.get("tools"):
|
|
body["tools"] = task["tools"]
|
|
|
|
resp = requests.post(
|
|
f"{base_url}/chat/completions",
|
|
headers={"authorization": f"Bearer {api_key}"},
|
|
json=body,
|
|
timeout=CALL_TIMEOUT_SECONDS,
|
|
)
|
|
resp.raise_for_status()
|
|
choice = (resp.json().get("choices") or [{}])[0]
|
|
message = choice.get("message") or {}
|
|
# finish_reason 'length' means the budget ran out mid-answer. Whatever
|
|
# came back is an artifact of the cap, so the caller skips rather than
|
|
# scoring it — the same rule as an unusable judge reply.
|
|
truncated = choice.get("finish_reason") == "length"
|
|
return message.get("content") or "", message.get("tool_calls") or [], truncated
|
|
|
|
|
|
JUDGE_SYSTEM = (
|
|
"You are scoring one model's answer against a rubric. Reply with ONLY a "
|
|
'JSON object: {"score": <number 0 to 1>, "reason": "<at most 12 words>"}. '
|
|
"Do not explain your reasoning outside the JSON. "
|
|
"Be strict: award a high score only if every rubric requirement is met."
|
|
)
|
|
|
|
JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
|
|
|
|
|
|
def extract_judge_json(raw: str) -> Optional[dict]:
|
|
"""Pull the score object out of a judge reply.
|
|
|
|
Judges are themselves reasoning models and leak their thinking into the
|
|
content despite response_format, so the object usually arrives wrapped in
|
|
prose ("Let me evaluate the answer against the rubric: ... {...}"). Before
|
|
this existed, 44% of judge calls were unparseable.
|
|
"""
|
|
if not raw:
|
|
return None
|
|
candidates = [raw]
|
|
match = JSON_OBJECT_RE.search(raw)
|
|
if match:
|
|
candidates.append(match.group(0))
|
|
for candidate in candidates:
|
|
try:
|
|
parsed = json.loads(candidate)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(parsed, dict) and "score" in parsed:
|
|
return parsed
|
|
return None
|
|
|
|
|
|
def score_judge(
|
|
base_url: str, api_key: str, judge_model: str, task: dict, answer: str
|
|
) -> Optional[tuple[float, str]]:
|
|
"""Score a prose task against its rubric.
|
|
|
|
Returns None when the judge itself failed, so the caller can SKIP the
|
|
task rather than record it. Scoring a judge malfunction as 0.0 would
|
|
charge the model for the judge's formatting — it put genuine 0.0 entries
|
|
in translation and general_chat on the first run, for answers that were
|
|
in fact fine.
|
|
"""
|
|
body = {
|
|
"model": judge_model,
|
|
"messages": [
|
|
{"role": "system", "content": JUDGE_SYSTEM},
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"TASK GIVEN TO THE MODEL:\n{task['prompt']}\n\n"
|
|
f"RUBRIC:\n{task['rubric']}\n\n"
|
|
f"THE MODEL'S ANSWER:\n{answer}"
|
|
),
|
|
},
|
|
],
|
|
"temperature": 0,
|
|
# Generous, because a reasoning judge spends tokens thinking before
|
|
# the JSON and a truncated object is unparseable.
|
|
"max_tokens": 1500,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
resp = requests.post(
|
|
f"{base_url}/chat/completions",
|
|
headers={"authorization": f"Bearer {api_key}"},
|
|
json=body,
|
|
timeout=CALL_TIMEOUT_SECONDS,
|
|
)
|
|
resp.raise_for_status()
|
|
raw = ((resp.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
parsed = extract_judge_json(raw)
|
|
if parsed is None:
|
|
return None
|
|
try:
|
|
score = float(parsed["score"])
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
return max(0.0, min(1.0, score)), str(parsed.get("reason", ""))[:70]
|
|
|
|
|
|
# --- selection ------------------------------------------------------------
|
|
|
|
def eval_identities(conn: sqlite3.Connection, cfg: RouterConfig) -> list[dict]:
|
|
"""Model rows worth measuring directly.
|
|
|
|
Flex rows are normally excluded: same weights, same reasoning setting,
|
|
different queue — their quality is the standard row's, inherited via
|
|
``propagate_to_variants``. ``-fast`` rows ARE measured, because reasoning
|
|
being off genuinely changes answers.
|
|
|
|
But that exclusion assumes an evaluated standard equivalent exists, and
|
|
it does not always: glm-5.2-flex reasons by default, while the only
|
|
routable standard glm row is glm-5.2-fast (reasoning reduced) — the
|
|
matching glm-5.2 is canary and therefore never evaluated. Such orphans
|
|
are measured directly rather than left with no proficiency at all.
|
|
"""
|
|
placeholders = ",".join("?" * len(cfg.routing.allowed_access_levels))
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT model_id, base_model_id, reasoning_mode, max_output_tokens FROM models m
|
|
WHERE availability = 'active'
|
|
AND access_level IN ({placeholders})
|
|
AND (
|
|
latency_class = 'standard'
|
|
-- ...or a flex row with no routable standard equivalent to
|
|
-- inherit from, which would otherwise score nothing at all
|
|
OR NOT EXISTS (
|
|
SELECT 1 FROM models s
|
|
WHERE s.base_model_id = m.base_model_id
|
|
AND s.provider = m.provider
|
|
AND s.reasoning_mode = m.reasoning_mode
|
|
AND s.context_variant = m.context_variant
|
|
AND s.latency_class = 'standard'
|
|
AND s.availability = 'active'
|
|
AND s.access_level IN ({placeholders})
|
|
)
|
|
)
|
|
ORDER BY model_id
|
|
""",
|
|
tuple(cfg.routing.allowed_access_levels) * 2,
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"model_id": r[0],
|
|
"base_model_id": r[1],
|
|
"reasoning_mode": r[2],
|
|
"max_output_tokens": r[3],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
# Tried in order when the configured judge belongs to the model under test.
|
|
# A LIST rather than one name, because a single alternate can itself be that
|
|
# model: with `--judge-model qwen3.6-35b`, the old guard fired on qwen3.6-35b
|
|
# and handed back qwen3.6-35b -- the exact conflict it exists to prevent, and
|
|
# silent, because self-judged scores look like any other judge output.
|
|
ALTERNATE_JUDGES = ("kimi-k3", "qwen3.6-35b", "gemma-4-31b")
|
|
|
|
|
|
def judge_for(model_id: str, default_judge: str) -> Optional[str]:
|
|
"""Pick a judge that is not the model being judged, or None.
|
|
|
|
A model scoring its own prose is a known bias, and the default judge is
|
|
itself in the evaluated set. Matching is on the FAMILY, so a -fast row does
|
|
not get judged by its reasoning-on sibling either -- same weights.
|
|
|
|
Returns None when every candidate shares the model's family, so the caller
|
|
skips the task. No sample beats a self-graded one, which is the same rule
|
|
score_judge already follows for a judge that returned nothing usable.
|
|
"""
|
|
from poller import parse_base_model_id
|
|
|
|
family = parse_base_model_id(model_id)
|
|
for candidate in (default_judge, *ALTERNATE_JUDGES):
|
|
if parse_base_model_id(candidate) != family:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--tasks", default=TASKS_PATH)
|
|
ap.add_argument("--models", help="comma-separated model_ids")
|
|
ap.add_argument("--categories", help="comma-separated categories")
|
|
ap.add_argument("--judge-model", default="kimi-k3")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
cfg = load_config("config.yaml")
|
|
tasks = (yaml.safe_load(Path(args.tasks).read_text()) or {}).get("tasks") or []
|
|
|
|
if args.categories:
|
|
wanted = {c.strip() for c in args.categories.split(",")}
|
|
tasks = [t for t in tasks if t["category"] in wanted]
|
|
|
|
unknown = {t["category"] for t in tasks} - set(cfg.proficiency.categories)
|
|
if unknown:
|
|
print(f"tasks reference unknown categories: {sorted(unknown)}", file=sys.stderr)
|
|
return 1
|
|
|
|
conn = sqlite3.connect(cfg.database.path)
|
|
identities = eval_identities(conn, cfg)
|
|
if args.models:
|
|
wanted = {m.strip() for m in args.models.split(",")}
|
|
identities = [i for i in identities if i["model_id"] in wanted]
|
|
|
|
if not identities or not tasks:
|
|
print("nothing to run", file=sys.stderr)
|
|
return 1
|
|
|
|
judge_tasks = sum(1 for t in tasks if t["kind"] == "judge")
|
|
print(
|
|
f"{len(identities)} models x {len(tasks)} tasks = "
|
|
f"{len(identities) * len(tasks)} calls"
|
|
+ (f" (+{len(identities) * judge_tasks} judge calls)" if judge_tasks else "")
|
|
)
|
|
if args.dry_run:
|
|
for i in identities:
|
|
budget = min(EVAL_MAX_TOKENS, i["max_output_tokens"] or EVAL_MAX_TOKENS)
|
|
print(
|
|
f" {i['model_id']:24s} reasoning={i['reasoning_mode']:8s} "
|
|
f"budget={budget}"
|
|
)
|
|
print(f" judge: {args.judge_model}")
|
|
return 0
|
|
|
|
settings = cfg.dispatch_providers["neuralwatt"]
|
|
api_key = os.environ.get(settings.api_key_env)
|
|
if not api_key:
|
|
print(f"{settings.api_key_env} is not set", file=sys.stderr)
|
|
return 1
|
|
|
|
for identity in identities:
|
|
model_id = identity["model_id"]
|
|
by_category: dict[str, list[float]] = defaultdict(list)
|
|
print(f"\n{model_id}")
|
|
|
|
for task in tasks:
|
|
try:
|
|
text, tool_calls, truncated = call_model(
|
|
settings.base_url,
|
|
api_key,
|
|
model_id,
|
|
task,
|
|
identity["max_output_tokens"],
|
|
)
|
|
except requests.RequestException as e:
|
|
print(f" {task['id']:30s} CALL FAILED {type(e).__name__}")
|
|
continue
|
|
|
|
if truncated and not tool_calls:
|
|
print(
|
|
f" {task['id']:30s} --- truncated at "
|
|
f"{EVAL_MAX_TOKENS} tokens, skipped"
|
|
)
|
|
continue
|
|
|
|
kind = task["kind"]
|
|
try:
|
|
if kind == "code":
|
|
score, detail = score_code(text, task["checks"])
|
|
elif kind == "exact":
|
|
score, detail = score_exact(text, str(task["answer"]))
|
|
elif kind == "tool":
|
|
score, detail = score_tool(tool_calls, task)
|
|
elif kind == "judge":
|
|
judge_model = judge_for(model_id, args.judge_model)
|
|
if judge_model is None:
|
|
print(
|
|
f" {task['id']:30s} --- no judge outside "
|
|
f"{model_id}'s family, skipped"
|
|
)
|
|
continue
|
|
judged = score_judge(
|
|
settings.base_url,
|
|
api_key,
|
|
judge_model,
|
|
task,
|
|
text,
|
|
)
|
|
if judged is None:
|
|
# No sample rather than a zero: a judge that failed to
|
|
# emit parseable JSON says nothing about the model.
|
|
print(f" {task['id']:30s} --- judge unusable, skipped")
|
|
continue
|
|
score, detail = judged
|
|
else:
|
|
print(f" {task['id']:30s} unknown kind {kind!r}")
|
|
continue
|
|
except requests.RequestException as e:
|
|
print(f" {task['id']:30s} SCORING FAILED {type(e).__name__}")
|
|
continue
|
|
|
|
by_category[task["category"]].append(score)
|
|
print(f" {task['id']:30s} {score:4.2f} {detail}")
|
|
|
|
for category, scores in by_category.items():
|
|
add_self_eval(conn, cfg, model_id, "neuralwatt", category, scores)
|
|
conn.commit()
|
|
|
|
# Equivalent serving variants inherit from the row actually measured —
|
|
# not from the family id, which may name a row that was never evaluated
|
|
# (glm-5.2 is canary, so glm-5.2-flex inherited nothing and scored blank).
|
|
propagated = 0
|
|
for identity in identities:
|
|
propagated += propagate_to_variants(
|
|
conn, cfg, identity["model_id"], "neuralwatt"
|
|
)
|
|
conn.commit()
|
|
print(f"\npropagated {propagated} inherited rows to serving variants")
|
|
conn.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|