Files
Dumitru Picalau ecb158e4cf feat(ai): short tool list for small models, and drop a 1$$ pick around a lone ref (#112)
Short tool list (Settings › AI assistant › Advanced, off by default): a small model in typed JSON steps gets one line per tool in its instructions, and each tool's arguments and example in the tail of the step that first offers it, once while visible. It is fixed per chat (meta tool_index), so the prompt prefix stays stable. The eval takes --lean.

9B A/B: content tied (55 vs 55 over three pairs) with about 14% fewer prompt tokens per step, so the switch stays off by default for now.

Also drop "{1$$@{…}}" around a lone ref, like the bare braces.
2026-10-24 00:27:52 +02:00

567 lines
26 KiB
Python
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.
"""Score the chat assistant against a model, offline.
Runs scripted chats through the real chat route (`POST /wp/api/ai/chat`) on
a COPY of a library database, with the model saved under Settings › AI
assistant (also copied, so nothing the run does reaches your settings).
Paused calls get canned answers: page tools say what a page would, web
searches are declined, questions are answered with their first choice.
Each turn is scored on:
- cards: cards in the answer, and repeats (two cards with the same title);
- errors: tool calls that failed;
- tests: run_test calls, and how many ran (not "nothing to run");
- did: whether the turn used what the scenario asks for (``expect``) and
made no cards where none were asked for;
- claims: the answer says it made or proposed a kind of item whose call
failed and has no card, or the server had to add "nothing was proposed".
Use it after a chat change, the way `ai_pack_evals.py` is used for the pack
builder; score each scenario by its average over several runs (``--runs``).
It is not part of CI: it needs a running model.
The ``smoke`` set (``--set smoke``) is ten short asks, one per feature a
small model gets wrong most (refs, tag filters, "only" constraints,
fallback, negatives, AND rules, a renamed variable, four items at once, a
how-to and a "where is"). Its cards are applied between turns (creates
only), so a later turn can build on them, and each turn can carry a
``check`` on the cards' content. Run it before and after each small-model
change (/pr-drafts/request-aware-prompts-plan.md); about fifteen minutes on
a 9B.
USAGE
-----
python scripts/ai_chat_evals.py --db PATH [--runs 3] [--only NAME,...]
[--set default|smoke] [--model NAME] [--out chat-evals.md]
[--sampling top_k=20,top_p=0.8,min_p=0,repeat_penalty=1.0]
[--write-temp 0.7] [--act-temp 0.15] [--seed 1] [--empty]
--sampling sets the copy's Settings › AI › Sampling (sent with each
request, so LM Studio's own settings stay untouched); --write-temp and
--act-temp override the small and medium profiles' temperatures for the
run (the reply step and the tool step). --seed sends a fixed sampling seed
with each request, the same for a scenario's run N in every report, so two
builds are compared on the same draws (seed + 1000 * run + scenario index).
``--db`` is the library to copy (default: the one ComfyUI would open).
Every scenario run starts from a fresh copy of it (and of the examples
file), so what one run's cards made can't steer the next. ``--empty``
starts every run from an empty library instead: a new user's first chat,
with nothing to copy from (the 9B lost about 4 of 20 smoke checks there,
2026-10-23).
Writes the report and a ``.json`` beside it with every turn's answer,
steps and cards. Record the model AND its chat template with each round.
"""
from __future__ import annotations
import argparse
import asyncio
import gc
import json
import os
import re
import shutil
import sys
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
#: Scripted chats. ``turns`` are the user's messages, in one chat; ``expect``
#: per turn: tools the turn should call (any one of a tuple), ``[]`` for a
#: turn that should make no cards.
SCENARIOS: list[dict[str, Any]] = [
{"name": "ideas", "page": {"route": "/library"},
"turns": ["What could I add to my library?"],
"expect": [None]},
{"name": "hair", "page": {"route": "/library"},
"turns": ["Make a hair colour wildcard with about 8 colours and test it."],
"expect": [["create_wildcard", "run_test"]]},
{"name": "season-outfit", "page": {"route": "/library"},
"turns": ["Make a Season wildcard and an Outfit wildcard, then a constraint so "
"winter never gets sandals or shorts."],
"expect": [["create_wildcard", "create_constraint"]]},
{"name": "explain", "page": {"route": "/library"},
"turns": ["How do constraints work? Keep it short."],
"expect": [[]]},
{"name": "title", "page": {"route": "/library"},
"turns": ["Make a Class wildcard (mage, warrior, rogue) and a derivation that sets "
"$title: mages are Archmage, warriors Champion, anyone else Adventurer."],
"expect": [["create_wildcard", "create_derivation"]]},
{"name": "weather-followup", "page": {"route": "/library"},
"turns": ["Make a weather wildcard.",
"Now add negatives to the stormy options so the sky isn't sunny.",
"Test it."],
"expect": [["create_wildcard"], [("edit_options", "create_wildcard")], ["run_test"]]},
{"name": "loop-cues", "page": {"route": "/library"},
"turns": ["In a Context Loop, I want the first image to say 'establishing shot' and "
"every later one 'close-up'. Make that and check it works."],
"expect": [["create_derivation", "run_test"]]},
{"name": "test-lighting", "page": {"route": "/library"},
"turns": ["Test my Lighting wildcard."],
"expect": [["run_test"]]},
]
def _texts(out: dict[str, Any]) -> str:
"""Everything the answer's cards would write, as one JSON string."""
return json.dumps([p.get("op") for p in out.get("proposals") or []], ensure_ascii=False)
#: A ref to the wildcard named {}: the model writes ``@{Name}``, the card
#: stores ``@{8hex#Name}`` once that wildcard exists (the smoke checks
#: missed every compiled ref until 2026-10-23).
_REF = r"@\{{(?:[0-9a-f]{{8}}#)?{}"
def _cards(tool: str, n: int) -> Callable[[dict[str, Any]], bool]:
return lambda out: sum(1 for p in out.get("proposals") or [] if p.get("tool") == tool) >= n
#: Small-model smoke set: one ask per feature. ``check`` per turn: a test of
#: the answer (its cards' ops are in ``_texts``), scored as "content".
SMOKE: list[dict[str, Any]] = [
{"name": "s-refs", "page": {"route": "/library"},
"turns": ["Make a Colors wildcard with 6 colours and no variable, so other wildcards "
"can draw from it.",
"Make an Outfit wildcard whose options draw their colour from Colors."],
"expect": [["create_wildcard"], ["create_wildcard"]],
"check": [None, lambda out: bool(re.search(_REF.format("Colors"), _texts(out)))]},
{"name": "s-tag-filter", "page": {"route": "/library"},
"turns": ["Make a Palette wildcard with no variable: 4 warm colours tagged warm and 4 "
"cool colours tagged cool.",
"Make a Sky wildcard whose options use only warm colours from Palette."],
"expect": [["create_wildcard"], ["create_wildcard"]],
"check": [None, lambda out: bool(re.search(_REF.format("Palette") + r":\s*warm",
_texts(out)))]},
{"name": "s-only", "page": {"route": "/library"},
"turns": ["Make a Setting wildcard (beach, office, forest) and an Outfit wildcard with "
"outfits tagged by the setting they suit, then make each setting get only "
"outfits that suit it."],
"expect": [["create_wildcard", "create_constraint"]],
"check": [lambda out: '"only"' in _texts(out)]},
{"name": "s-fallback", "page": {"route": "/library"},
"turns": ["Make a Weather wildcard with 5 options where clear sky is the fallback when "
"rules rule out everything else."],
"expect": [["create_wildcard"]],
"check": [lambda out: '"fallback": true' in _texts(out)]},
{"name": "s-negatives", "page": {"route": "/library"},
"turns": ["Make a Lighting wildcard with 4 options: two dark, two bright.",
"Add negatives to the dark options so the image isn't overexposed."],
"expect": [["create_wildcard"], [("edit_options", "create_wildcard")]],
"check": [None, lambda out: '"negative": "' in _texts(out)]},
{"name": "s-and-rule", "page": {"route": "/library"},
"turns": ["Make a Class wildcard (mage, warrior) and a Season wildcard (winter, summer), "
"then a derivation that sets $cloak to 'fur cloak' when the class is warrior "
"AND the season is winter, else nothing."],
"expect": [["create_wildcard", "create_derivation"]],
"check": [lambda out: '"match": "all"' in _texts(out)
and all(w in _texts(out) for w in ("warrior", "winter"))]},
{"name": "s-rename-var", "page": {"route": "/library"},
"turns": ["Make a Hair wildcard bound to $hair with 4 options.",
"Rename its variable to hair_color."],
"expect": [["create_wildcard"], [("update_info", "edit_options", "variables")]],
"check": [None, lambda out: "hair_color" in _texts(out)]},
{"name": "s-four-items", "page": {"route": "/library"},
"turns": ["Make four wildcards: Eyes, Pose, Expression and Background, each with 5 "
"options."],
"expect": [["create_wildcard"]],
"check": [_cards("create_wildcard", 4)]},
{"name": "s-how-to", "page": {"route": "/library"},
"turns": ["How does a constraint's only mode work? Keep it short."],
"expect": [[]],
"check": [lambda out: any(s.get("tool") in ("explain", "docs_search", "docs_read")
for s in out.get("steps") or [])]},
{"name": "s-where", "page": {"route": "/library"},
"turns": ["Where do I turn on deleting for the AI assistant?"],
"expect": [[]],
"check": [lambda out: bool(re.search(
r"permission|let the assistant delete", str(out.get("message") or ""), re.I))]},
]
SETS = {"default": SCENARIOS, "smoke": SMOKE}
#: Where each kind of create card is applied (the drawer's Apply, creates only).
_APPLY = {"module": "/wp/api/modules", "bundle": "/wp/api/bundles",
"template": "/wp/api/templates"}
async def apply_cards(client: Any, out: dict[str, Any]) -> list[dict[str, Any]]:
"""Apply the answer's create cards like the drawer does, so the next turn
can build on them; the events the next message reports."""
events = []
for p in out.get("proposals") or []:
op = p.get("op") or {}
path = _APPLY.get(str(op.get("kind")))
if op.get("op") != "create" or not path or not isinstance(op.get("body"), dict):
continue
body = {"category_id": None, "description": "", "tags": [], **op["body"]}
resp = await client.post(path, json=body)
if resp.status in (200, 201):
row = await resp.json()
events.append({"pid": p.get("pid"), "status": "applied", "id": row.get("id")})
return events
#: What a page tool answers in the eval (no browser).
PAGE_ANSWERS = {
"read_page": "The page shows the library list: wildcards, derivations, constraints, "
"bundles. No item is selected.",
"open": "Opened.",
"highlight": "Highlighted.",
}
#: Words that say the answer made something ("I've created", "I proposed").
_CLAIM = re.compile(r"\b(I(?:'ve| have)? (?:created|added|made|proposed|set up)|"
r"proposed|created)\b", re.I)
_KIND_OF = {"create_constraint": "constraint", "create_derivation": "derivation",
"create_wildcard": "wildcard", "create_bundle": "bundle",
"create_template": "template", "create_combine": "combine",
"create_fixed_values": "fixed values"}
def _answers(pending: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for c in pending:
tool = c["tool"]
if tool == "ask":
qs = (c.get("args") or {}).get("questions") or []
out.append({"id": c["id"], "answers": [{"q": i, "picked": [0]}
for i in range(len(qs))]})
elif tool in ("web_search", "read_result", "danbooru"):
out.append({"id": c["id"], "denied": True})
else:
out.append({"id": c["id"], "text": PAGE_ANSWERS.get(tool, "Done.")})
return out
def score_turn(out: dict[str, Any], expect: Any, check: Any = None) -> dict[str, Any]:
"""One answer's numbers (see the module docstring)."""
proposals = out.get("proposals") or []
steps = out.get("steps") or []
titles = [p.get("title") for p in proposals]
repeats = len(titles) - len(set(titles))
errors = [s for s in steps if not s.get("ok")]
tests = [s for s in steps if s.get("tool") == "run_test"]
ran = [s for s in tests if s.get("ok") and s.get("card")]
used = {s.get("tool") for s in steps if s.get("ok")} | {p.get("tool") for p in proposals}
did = None
if expect is not None:
if expect == []:
did = not proposals
else:
did = all(any(t in used for t in (e if isinstance(e, tuple) else (e,)))
for e in expect)
message = str(out.get("message") or "")
made = {p.get("tool") for p in proposals}
claims = []
for s in errors:
kind = _KIND_OF.get(str(s.get("tool")))
if kind and s.get("tool") not in made and re.search(
rf"\b{re.escape(kind)}\b", message, re.I) and _CLAIM.search(message):
claims.append(f"says a {kind} was made; its call failed")
if "nothing was proposed" in message.lower() or "no card" in message.lower():
claims.append("claimed a change with no card")
if "no test ran in this answer" in message.lower():
claims.append("talked about a test that never ran")
content = None
if callable(check):
try:
content = bool(check(out))
except Exception: # a check that can't read the answer failed it
content = False
return {"content": content, "cards": len(proposals), "repeats": repeats,
"errors": len(errors),
"tests": len(tests), "tests_ran": len(ran), "did": did,
"claims": sorted(set(claims)), "steps": len(steps),
"card_warnings": sum(len(p.get("warnings") or []) for p in proposals),
# what the cards would write, so a fixed check can re-score a run
"ops": [p.get("op") for p in proposals]}
async def run_scenario(client: Any, sc: dict[str, Any]) -> list[dict[str, Any]]:
chat_id = None
turns = []
events: list[dict[str, Any]] = []
for n, text in enumerate(sc["turns"]):
started = time.perf_counter()
body: dict[str, Any] = {"message": text, "page": sc.get("page") or {}}
if chat_id:
body["chat_id"] = chat_id
if events:
body["events"] = events
resp = await client.post("/wp/api/ai/chat", json=body)
out = await resp.json()
rounds = 0
while resp.status == 200 and out.get("pending") and rounds < 4:
rounds += 1
resp = await client.post("/wp/api/ai/chat", json={
"chat_id": out["chat_id"], "results": _answers(out["pending"]),
"page": sc.get("page") or {}})
nxt = await resp.json()
if resp.status == 200:
# Steps and cards before the pause stay part of this turn.
nxt["steps"] = [*(out.get("steps") or []), *(nxt.get("steps") or [])]
nxt["proposals"] = [*(out.get("proposals") or []),
*(nxt.get("proposals") or [])]
out = nxt
seconds = round(time.perf_counter() - started, 1)
if resp.status != 200:
turns.append({"text": text, "error": out.get("error") or resp.status,
"seconds": seconds})
break
chat_id = out.get("chat_id")
expect = (sc.get("expect") or [None] * len(sc["turns"]))[n]
check = (sc.get("check") or [None] * len(sc["turns"]))[n]
events = await apply_cards(client, out) if sc.get("check") else []
turns.append({
"text": text, "seconds": seconds, **score_turn(out, expect, check),
"message": out.get("message"),
"step_notes": [(s.get("tool"), s.get("ok"), s.get("note")) for s in
out.get("steps") or []],
"card_titles": [p.get("title") for p in out.get("proposals") or []],
"warnings": [w for p in out.get("proposals") or [] for w in
p.get("warnings") or []][:12],
})
return turns
def report(rows: list[dict[str, Any]], model: str, note: str) -> str:
turns = [t for r in rows for t in r["turns"] if "error" not in t]
failed = [t for r in rows for t in r["turns"] if "error" in t]
def mean(key: str) -> float:
return sum(t[key] for t in turns) / len(turns) if turns else 0
judged = [t for t in turns if t["did"] is not None]
checked = [t for t in turns if t.get("content") is not None]
tests = sum(t["tests"] for t in turns)
lines = [
f"# Chat evals: {model}", "", note, "",
f"- Turns: {len(turns)} ({len(failed)} failed outright)",
f"- Did what was asked: {sum(1 for t in judged if t['did'])} / {len(judged)}",
f"- Content checks passed: {sum(1 for t in checked if t['content'])} / {len(checked)}",
f"- Cards per answer: {mean('cards'):.1f}; repeated cards: "
f"{sum(t['repeats'] for t in turns)}",
f"- Tool errors per turn: {mean('errors'):.2f}",
f"- run_test: {sum(t['tests_ran'] for t in turns)} ran of {tests} calls",
f"- Answers with a false claim: {sum(1 for t in turns if t['claims'])}",
f"- Seconds per turn: {mean('seconds'):.0f}", "",
"| Scenario | Run | Turn | Did | Content | Cards | Repeats | Errors | Tests ran | Claims "
"| s |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
for r in rows:
for n, t in enumerate(r["turns"], 1):
if "error" in t:
lines.append(f"| {r['name']} | {r['run']} | {n} | error: "
f"{str(t['error'])[:60]} | | | | | | | {t['seconds']} |")
continue
did = "" if t["did"] is None else ("yes" if t["did"] else "NO")
ok = "" if t.get("content") is None else ("yes" if t["content"] else "NO")
lines.append(f"| {r['name']} | {r['run']} | {n} | {did} | {ok} | {t['cards']} | "
f"{t['repeats']} | {t['errors']} | {t['tests_ran']}/{t['tests']} | "
f"{'; '.join(t['claims'])[:60]} | {t['seconds']} |")
return "\n".join(lines) + "\n"
def fresh_copy(work: Path) -> Callable[[], None]:
"""What puts `work`'s library and examples back as they are now: the
smoke set's second run found the first run's Hair and Outfit and edited
those (2026-10-23). The search index beside the library is dropped, so
it is rebuilt from the copy. The library each run leaves is kept as
``runs/run-NNN.db`` first, so its chats' traces survive for
`ai_trace_report.py --db <work>/runs`."""
keep = work / "pristine"
keep.mkdir(exist_ok=True)
runs = work / "runs"
runs.mkdir(exist_ok=True)
used = [0]
names = ("library.db", "ai-examples.json")
for n in names:
if (work / n).exists():
shutil.copy2(work / n, keep / n)
def restore() -> None:
# The chat keeps its search index open; Windows can't delete an
# open file (WinError 32 on Dumi's PC, 2026-10-23).
from wp_api import _ai_context
for idx in _ai_context._INDEXES.values():
if idx is not None:
idx.close()
_ai_context._INDEXES.clear()
gc.collect() # and any library connection nobody closed
if used[0] and (work / "library.db").exists():
shutil.copy2(work / "library.db", runs / f"run-{used[0]:03d}.db")
used[0] += 1
for p in work.iterdir():
if p.is_file() and (p.name.startswith(("library.db", "ai-examples.json"))
or ".ai-index.db" in p.name):
p.unlink()
for n in names:
if (keep / n).exists():
shutil.copy2(keep / n, work / n)
return restore
#: The seed the next requests carry (``--seed``); None sends none.
_SEED: list[int | None] = [None]
def seed_requests(base: int) -> None:
"""Add ``_SEED`` to every chat request's sampling fields."""
from wp_api import _ai_client
plain = _ai_client.sampling_fields
def seeded(cfg: dict[str, Any]) -> dict[str, Any]:
out = plain(cfg)
return out if _SEED[0] is None else {**out, "seed": _SEED[0]}
_ai_client.sampling_fields = seeded # type: ignore[assignment]
_SEED[0] = base
async def evaluate(scenarios: list[dict[str, Any]], runs: int,
partial: Callable[[list[dict[str, Any]]], None] | None = None,
fresh: Callable[[], None] | None = None,
) -> list[dict[str, Any]]:
"""Every scenario ``runs`` times, each from `fresh` (a clean library).
No client timeout: a slow turn on a shared or wordy model must not lose
the run. ``partial`` gets the rows so far after each scenario, so a
killed run still leaves a report."""
from aiohttp import ClientTimeout, web
from aiohttp.test_utils import TestClient, TestServer
import wp_api
wp_api._start_backups = lambda: None # type: ignore[attr-defined]
app = web.Application(client_max_size=32 * 1024 * 1024)
wp_api.register_routes(app)
rows: list[dict[str, Any]] = []
async with TestClient(TestServer(app), timeout=ClientTimeout(None)) as client:
base = _SEED[0]
for run in range(1, runs + 1):
for i, sc in enumerate(scenarios):
if fresh is not None:
fresh()
if base is not None:
_SEED[0] = base + 1000 * run + i
print(f"run {run} · {sc['name']}", flush=True)
rows.append({"name": sc["name"], "run": run,
"turns": await run_scenario(client, sc)})
if partial is not None:
partial(rows)
if fresh is not None:
fresh() # keeps the last run's library too
return rows
def parse_sampling(text: str) -> dict[str, float]:
""""top_k=20,top_p=0.8" as a Sampling patch; ValueError on a bad pair."""
out: dict[str, float] = {}
for pair in filter(None, (p.strip() for p in text.split(","))):
name, sep, value = pair.partition("=")
if not sep:
raise ValueError(f"--sampling: {pair!r} is not name=value")
out[name.strip()] = float(value)
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--db", default="", help="library database to copy")
ap.add_argument("--empty", action="store_true",
help="start every run from an empty library (a new user)")
ap.add_argument("--runs", type=int, default=3)
ap.add_argument("--only", default="", help="scenario names, comma-separated")
ap.add_argument("--set", default="default", choices=sorted(SETS),
help="which scenarios: the default chats or the small-model smoke set")
ap.add_argument("--model", default="")
ap.add_argument("--note", default="", help="model, chat template, commit")
ap.add_argument("--out", default="chat-evals.md")
ap.add_argument("--sampling", default="", help="name=value,… (Settings › AI › Sampling)")
ap.add_argument("--write-temp", type=float, default=None,
help="reply-step temperature for small and medium models")
ap.add_argument("--act-temp", type=float, default=None,
help="tool-step temperature for small and medium models")
ap.add_argument("--lean", action="store_true",
help="lean prompt: one line per tool (Settings › AI › Advanced)")
ap.add_argument("--seed", type=int, default=None,
help="fixed sampling seed, so two builds see the same draws")
args = ap.parse_args()
from engine.ai import config as ai_config
from engine.db.connection import resolve_db_path
src_db = Path(args.db) if args.db else resolve_db_path()
src_cfg = ai_config.config_path()
work = Path(tempfile.mkdtemp(prefix="wp-chat-eval-"))
if args.empty:
# The chat route reads the tables, so an empty library still
# needs them ("no such table: modules" otherwise).
from engine.db.connection import get_connection
from engine.db.migrations import migrate
conn = get_connection(work / "library.db")
migrate(conn)
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") # the copies take the .db only
conn.close()
elif src_db.exists():
shutil.copy2(src_db, work / "library.db")
if src_cfg.exists():
shutil.copy2(src_cfg, work / "ai.json")
os.environ["WP_DB_PATH"] = str(work / "library.db")
os.environ["WP_AI_CONFIG_PATH"] = str(work / "ai.json")
os.environ["WP_SETTINGS_PATH"] = str(work / "wp-settings.json")
cfg = ai_config.load_effective()
if args.model:
ai_config.update({"model": args.model})
cfg = ai_config.load_effective()
if args.sampling:
ai_config.update({"sampling": parse_sampling(args.sampling)})
cfg = ai_config.load_effective()
if args.lean:
ai_config.update({"lean_prompt": True})
cfg = ai_config.load_effective()
from engine.ai import budget as ai_budget
for size in ("small", "medium"):
if args.write_temp is not None:
ai_budget.PROFILES[size]["write_temp"] = args.write_temp
if args.act_temp is not None:
ai_budget.PROFILES[size]["act_temp"] = args.act_temp
if args.seed is not None:
seed_requests(args.seed)
if not cfg.get("enabled") or not cfg.get("model"):
print("The AI assistant is off or has no model. Set it up under Settings › AI "
"assistant, or pass --model.")
return 0
only = {s.strip() for s in args.only.split(",") if s.strip()}
scenarios = [s for s in SETS[args.set] if not only or s["name"] in only]
print(f"Library copy and settings in {work}; model {cfg['model']}; sampling "
f"{cfg.get('sampling') or 'server'}; temps act "
f"{ai_budget.PROFILES['small']['act_temp']} write "
f"{ai_budget.PROFILES['small']['write_temp']}; seed {args.seed}"
+ ("; empty library" if args.empty else "")
+ ("; lean prompt" if args.lean else ""), flush=True)
def write(rows: list[dict[str, Any]]) -> None:
Path(args.out).write_text(report(rows, cfg["model"], args.note), encoding="utf-8")
Path(args.out).with_suffix(".json").write_text(
json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
write(asyncio.run(evaluate(scenarios, args.runs, partial=write, fresh=fresh_copy(work))))
print(f"Wrote {args.out}; traces: python scripts/ai_trace_report.py --db {work / 'runs'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())