mistrial 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mistrial might be problematic. Click here for more details.
- mistrial/__init__.py +7 -0
- mistrial/cache.py +61 -0
- mistrial/cases.py +130 -0
- mistrial/cli.py +453 -0
- mistrial/data/demo_cases.jsonl +20 -0
- mistrial/judges.py +571 -0
- mistrial/probes.py +411 -0
- mistrial/prompts.py +136 -0
- mistrial/report.py +604 -0
- mistrial/runner.py +170 -0
- mistrial/stats.py +555 -0
- mistrial-0.2.0.dist-info/METADATA +463 -0
- mistrial-0.2.0.dist-info/RECORD +16 -0
- mistrial-0.2.0.dist-info/WHEEL +4 -0
- mistrial-0.2.0.dist-info/entry_points.txt +2 -0
- mistrial-0.2.0.dist-info/licenses/LICENSE +21 -0
mistrial/__init__.py
ADDED
mistrial/cache.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""A small sqlite cache of raw judge responses, so re-running a report is free.
|
|
2
|
+
|
|
3
|
+
Keyed on everything that changes the answer: judge identity, model, the exact prompt (or the
|
|
4
|
+
case for callables), and the run index. Pass --fresh to bypass it and make new calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
DEFAULT_DIR = Path(".mistrial")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def key_for(*parts: Any) -> str:
|
|
20
|
+
h = hashlib.sha256()
|
|
21
|
+
for p in parts:
|
|
22
|
+
h.update(json.dumps(p, sort_keys=True, default=str).encode())
|
|
23
|
+
h.update(b"\x1f")
|
|
24
|
+
return h.hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Cache:
|
|
28
|
+
def __init__(self, path: Path | None = None, enabled: bool = True):
|
|
29
|
+
self.enabled = enabled
|
|
30
|
+
self.path = path or (DEFAULT_DIR / "cache.sqlite")
|
|
31
|
+
self._conn: sqlite3.Connection | None = None
|
|
32
|
+
|
|
33
|
+
def _db(self) -> sqlite3.Connection:
|
|
34
|
+
if self._conn is None:
|
|
35
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self._conn = sqlite3.connect(self.path)
|
|
37
|
+
self._conn.execute(
|
|
38
|
+
"CREATE TABLE IF NOT EXISTS responses (key TEXT PRIMARY KEY, value TEXT NOT NULL, created REAL NOT NULL)"
|
|
39
|
+
)
|
|
40
|
+
return self._conn
|
|
41
|
+
|
|
42
|
+
def get(self, key: str) -> Any | None:
|
|
43
|
+
if not self.enabled:
|
|
44
|
+
return None
|
|
45
|
+
row = self._db().execute("SELECT value FROM responses WHERE key = ?", (key,)).fetchone()
|
|
46
|
+
return json.loads(row[0]) if row else None
|
|
47
|
+
|
|
48
|
+
def put(self, key: str, value: Any) -> None:
|
|
49
|
+
if not self.enabled:
|
|
50
|
+
return
|
|
51
|
+
db = self._db()
|
|
52
|
+
db.execute(
|
|
53
|
+
"INSERT OR REPLACE INTO responses (key, value, created) VALUES (?, ?, ?)",
|
|
54
|
+
(key, json.dumps(value, default=str), time.time()),
|
|
55
|
+
)
|
|
56
|
+
db.commit()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if self._conn is not None:
|
|
60
|
+
self._conn.close()
|
|
61
|
+
self._conn = None
|
mistrial/cases.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Cases: the things a judge is asked to score.
|
|
2
|
+
|
|
3
|
+
A case is what you'd hand to a LangSmith or openevals evaluator: `inputs`, `outputs`, and
|
|
4
|
+
optionally `reference_outputs`. A human `label` is optional too; when present the report
|
|
5
|
+
measures the judges against it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
INPUT_KEYS = ("inputs", "input", "question", "prompt", "query")
|
|
17
|
+
OUTPUT_KEYS = ("outputs", "output", "answer", "response", "completion", "prediction")
|
|
18
|
+
REFERENCE_KEYS = (
|
|
19
|
+
"reference_outputs",
|
|
20
|
+
"reference_output",
|
|
21
|
+
"reference",
|
|
22
|
+
"expected",
|
|
23
|
+
"expected_output",
|
|
24
|
+
"expected_outputs",
|
|
25
|
+
"ground_truth_answer",
|
|
26
|
+
)
|
|
27
|
+
LABEL_KEYS = ("label", "labels", "human", "human_label", "ground_truth", "expected_score", "gold")
|
|
28
|
+
ID_KEYS = ("id", "case_id", "example_id", "name")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Case:
|
|
33
|
+
id: str
|
|
34
|
+
inputs: Any
|
|
35
|
+
outputs: Any
|
|
36
|
+
reference_outputs: Any = None
|
|
37
|
+
label: Any = None
|
|
38
|
+
metadata: dict = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"id": self.id,
|
|
43
|
+
"inputs": self.inputs,
|
|
44
|
+
"outputs": self.outputs,
|
|
45
|
+
"reference_outputs": self.reference_outputs,
|
|
46
|
+
"label": self.label,
|
|
47
|
+
"metadata": self.metadata,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pick(row: dict, keys: Iterable[str]) -> tuple[bool, Any]:
|
|
52
|
+
for k in keys:
|
|
53
|
+
if k in row:
|
|
54
|
+
return True, row[k]
|
|
55
|
+
return False, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def case_from_dict(row: dict, index: int) -> Case:
|
|
59
|
+
"""Build a Case from a loosely-shaped dict, accepting the usual alias keys."""
|
|
60
|
+
if not isinstance(row, dict):
|
|
61
|
+
raise ValueError(f"case {index}: expected an object, got {type(row).__name__}")
|
|
62
|
+
has_in, inputs = _pick(row, INPUT_KEYS)
|
|
63
|
+
has_out, outputs = _pick(row, OUTPUT_KEYS)
|
|
64
|
+
if not has_out:
|
|
65
|
+
raise ValueError(f"case {index}: needs an 'outputs' (or 'output'/'answer'/'response') field")
|
|
66
|
+
_, reference = _pick(row, REFERENCE_KEYS)
|
|
67
|
+
_, label = _pick(row, LABEL_KEYS)
|
|
68
|
+
has_id, cid = _pick(row, ID_KEYS)
|
|
69
|
+
known = set(INPUT_KEYS) | set(OUTPUT_KEYS) | set(REFERENCE_KEYS) | set(LABEL_KEYS) | set(ID_KEYS)
|
|
70
|
+
metadata = {k: v for k, v in row.items() if k not in known}
|
|
71
|
+
return Case(
|
|
72
|
+
id=str(cid) if has_id and cid not in (None, "") else f"case-{index + 1:03d}",
|
|
73
|
+
inputs=inputs if has_in else None,
|
|
74
|
+
outputs=outputs,
|
|
75
|
+
reference_outputs=reference,
|
|
76
|
+
label=_coerce_label(label),
|
|
77
|
+
metadata=metadata,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _coerce_label(value: Any) -> Any:
|
|
82
|
+
"""Labels from CSV arrive as strings; turn 'true'/'1'/'0.5' into real values."""
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
s = value.strip()
|
|
85
|
+
if s == "":
|
|
86
|
+
return None
|
|
87
|
+
if s.lower() in ("true", "yes", "pass", "correct"):
|
|
88
|
+
return True
|
|
89
|
+
if s.lower() in ("false", "no", "fail", "incorrect"):
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
return int(s)
|
|
93
|
+
except ValueError:
|
|
94
|
+
pass
|
|
95
|
+
try:
|
|
96
|
+
return float(s)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return s
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_cases(path: str | Path) -> list[Case]:
|
|
103
|
+
"""Load cases from .jsonl, .json (a list or {"cases": [...]}), or .csv."""
|
|
104
|
+
p = Path(path)
|
|
105
|
+
if not p.exists():
|
|
106
|
+
raise FileNotFoundError(p)
|
|
107
|
+
suffix = p.suffix.lower()
|
|
108
|
+
rows: list[dict]
|
|
109
|
+
if suffix == ".jsonl":
|
|
110
|
+
rows = [json.loads(line) for line in p.read_text().splitlines() if line.strip()]
|
|
111
|
+
elif suffix == ".json":
|
|
112
|
+
data = json.loads(p.read_text())
|
|
113
|
+
if isinstance(data, dict):
|
|
114
|
+
data = data.get("cases") or data.get("examples") or data.get("data")
|
|
115
|
+
if not isinstance(data, list):
|
|
116
|
+
raise ValueError(f"{p}: expected a list of cases (or an object with a 'cases' list)")
|
|
117
|
+
rows = data
|
|
118
|
+
elif suffix == ".csv":
|
|
119
|
+
with p.open(newline="") as f:
|
|
120
|
+
rows = [dict(r) for r in csv.DictReader(f)]
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"{p}: unsupported format {suffix!r} (use .jsonl, .json or .csv)")
|
|
123
|
+
cases = [case_from_dict(r, i) for i, r in enumerate(rows)]
|
|
124
|
+
ids = [c.id for c in cases]
|
|
125
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
126
|
+
if dupes:
|
|
127
|
+
raise ValueError(f"{p}: duplicate case ids: {', '.join(dupes[:5])}")
|
|
128
|
+
if not cases:
|
|
129
|
+
raise ValueError(f"{p}: no cases found")
|
|
130
|
+
return cases
|
mistrial/cli.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""mistrial command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import json
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .cache import DEFAULT_DIR, Cache
|
|
18
|
+
from .cases import load_cases
|
|
19
|
+
from .judges import PROVIDERS, StabilizedJudge, build_judge
|
|
20
|
+
from .probes import (
|
|
21
|
+
migrate,
|
|
22
|
+
print_migrate,
|
|
23
|
+
print_rubric_check,
|
|
24
|
+
print_sentinel,
|
|
25
|
+
push_to_langsmith,
|
|
26
|
+
rubric_check,
|
|
27
|
+
sentinel_baseline,
|
|
28
|
+
sentinel_check,
|
|
29
|
+
write_escalation_csv,
|
|
30
|
+
)
|
|
31
|
+
from .prompts import RUBRICS
|
|
32
|
+
from .report import analyze, compare, escalation, print_compare, print_report, write_outputs
|
|
33
|
+
from .runner import RunResult, run
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
add_completion=False,
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
help="Is your LLM judge flaky? Re-run any evaluator across runs and judge models and get a reliability card.",
|
|
39
|
+
)
|
|
40
|
+
console = Console()
|
|
41
|
+
err = Console(stderr=True)
|
|
42
|
+
|
|
43
|
+
JUDGE_HELP = "Judge spec, repeatable: <provider>:<model> (openai, anthropic, gemini, groq, mistral, deepseek, xai, together, openrouter, ollama, custom; see `mistrial providers`), module:function, demo[:flaky|steady|lenient|score]."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version(value: bool) -> None:
|
|
47
|
+
if value:
|
|
48
|
+
console.print(f"mistrial {__version__}")
|
|
49
|
+
raise typer.Exit()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback()
|
|
53
|
+
def main(version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show the version and exit.")) -> None:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _html_path(value: str | None) -> Optional[Path]:
|
|
58
|
+
if value is None or value.strip().lower() in ("", "none", "-", "."):
|
|
59
|
+
return None
|
|
60
|
+
return Path(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_judges(judge_specs: list[str], rubric: str, temperature, reasoning_effort, max_tokens: int, stabilize: int = 0) -> list:
|
|
64
|
+
try:
|
|
65
|
+
judges = [build_judge(s, rubric=rubric, temperature=temperature, reasoning_effort=reasoning_effort, max_tokens=max_tokens) for s in judge_specs]
|
|
66
|
+
except (ValueError, ImportError, AttributeError) as e:
|
|
67
|
+
err.print(f"[red]judge error:[/red] {e}")
|
|
68
|
+
raise typer.Exit(2)
|
|
69
|
+
if stabilize and stabilize > 1:
|
|
70
|
+
judges = judges + [StabilizedJudge(j, k=stabilize) for j in judges]
|
|
71
|
+
names = [j.name for j in judges]
|
|
72
|
+
if len(set(names)) != len(names):
|
|
73
|
+
err.print("[red]two judges have the same name; give one a different model or preset[/red]")
|
|
74
|
+
raise typer.Exit(2)
|
|
75
|
+
return judges
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _progress(quiet: bool, total: int):
|
|
79
|
+
bar = Progress(TextColumn("[progress.description]{task.description}"), BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), console=console, disable=quiet)
|
|
80
|
+
task = bar.add_task("judging", total=total)
|
|
81
|
+
|
|
82
|
+
def tick(done: int, new_total: int, _v: object) -> None:
|
|
83
|
+
bar.update(task, completed=done, total=new_total)
|
|
84
|
+
|
|
85
|
+
return bar, tick
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _gate_failures(report: dict, max_flip_rate, min_self_agreement, min_agreement) -> list[str]:
|
|
89
|
+
failed = []
|
|
90
|
+
if max_flip_rate is not None:
|
|
91
|
+
for j in report["judges"]:
|
|
92
|
+
if j["flip_rate"] > max_flip_rate:
|
|
93
|
+
failed.append(f"{j['name']} flip rate {j['flip_rate']:.0%} > {max_flip_rate:.0%}")
|
|
94
|
+
if min_self_agreement is not None:
|
|
95
|
+
for j in report["judges"]:
|
|
96
|
+
sa = j["self_agreement"]
|
|
97
|
+
if sa is not None and sa < min_self_agreement:
|
|
98
|
+
failed.append(f"{j['name']} self-agreement {sa:.0%} < {min_self_agreement:.0%}")
|
|
99
|
+
if min_agreement is not None and report.get("agreement") and report["agreement"]["alpha_majority"] is not None:
|
|
100
|
+
a = report["agreement"]["alpha_majority"]
|
|
101
|
+
if a < min_agreement:
|
|
102
|
+
failed.append(f"judge agreement alpha {a:.2f} < {min_agreement:.2f}")
|
|
103
|
+
for j in report["judges"]:
|
|
104
|
+
if j["errors"] and j["errors"] == j["cases"] * j["runs"]:
|
|
105
|
+
failed.append(f"{j['name']}: every call failed")
|
|
106
|
+
return failed
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_and_report(
|
|
110
|
+
cases_path: Path,
|
|
111
|
+
judge_specs: list[str],
|
|
112
|
+
runs: int,
|
|
113
|
+
rubric: str,
|
|
114
|
+
concurrency: int,
|
|
115
|
+
fresh: bool,
|
|
116
|
+
out_dir: Path,
|
|
117
|
+
html: Optional[Path],
|
|
118
|
+
json_out: Optional[Path],
|
|
119
|
+
max_flip_rate: Optional[float],
|
|
120
|
+
min_agreement: Optional[float],
|
|
121
|
+
min_self_agreement: Optional[float],
|
|
122
|
+
top: int,
|
|
123
|
+
temperature: Optional[float],
|
|
124
|
+
reasoning_effort: Optional[str],
|
|
125
|
+
max_tokens: int,
|
|
126
|
+
label: Optional[str],
|
|
127
|
+
quiet: bool,
|
|
128
|
+
title: str,
|
|
129
|
+
adaptive: bool = False,
|
|
130
|
+
stabilize: int = 0,
|
|
131
|
+
pass_mark: Optional[float] = None,
|
|
132
|
+
runs_in_ci: int = 1,
|
|
133
|
+
markdown: Optional[Path] = None,
|
|
134
|
+
) -> int:
|
|
135
|
+
cases = load_cases(cases_path)
|
|
136
|
+
judges = _build_judges(judge_specs, rubric, temperature, reasoning_effort, max_tokens, stabilize)
|
|
137
|
+
cache = Cache(out_dir / "cache.sqlite", enabled=not fresh)
|
|
138
|
+
planned = len(cases) * len(judges) * (2 if adaptive and runs > 2 else runs)
|
|
139
|
+
if not quiet:
|
|
140
|
+
mode = " (adaptive: 2 runs each, more only where they disagree)" if adaptive and runs > 2 else ""
|
|
141
|
+
console.print(f"mistrial · {len(cases)} cases × {len(judges)} judges × {runs} runs{mode}" + (" · fresh, no cache" if fresh else ""))
|
|
142
|
+
bar, tick = _progress(quiet, planned)
|
|
143
|
+
with bar:
|
|
144
|
+
result = asyncio.run(run(cases, judges, runs=runs, concurrency=concurrency, cache=cache, progress=tick, rubric=rubric, label=label, adaptive=adaptive))
|
|
145
|
+
|
|
146
|
+
report = analyze(result, top=top, pass_mark=pass_mark, runs_in_ci=runs_in_ci)
|
|
147
|
+
if not quiet:
|
|
148
|
+
print_report(report, console)
|
|
149
|
+
failed = _gate_failures(report, max_flip_rate, min_self_agreement, min_agreement)
|
|
150
|
+
|
|
151
|
+
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
152
|
+
saved = out_dir / "runs" / f"{stamp}.json"
|
|
153
|
+
write_outputs(report, result, html_path=html, json_path=saved, title=title, markdown_path=markdown, gate_failures=failed)
|
|
154
|
+
if json_out and json_out != saved:
|
|
155
|
+
write_outputs(report, result, html_path=None, json_path=json_out, title=title)
|
|
156
|
+
if not quiet:
|
|
157
|
+
console.print(f"saved run → {saved}" + (f"\nreport → {html}" if html else "") + (f"\nmarkdown → {markdown}" if markdown else ""))
|
|
158
|
+
if failed:
|
|
159
|
+
err.print("[red]gate failed:[/red] " + "; ".join(failed))
|
|
160
|
+
return 1
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command("run")
|
|
165
|
+
def run_cmd(
|
|
166
|
+
cases: Path = typer.Argument(..., help="Cases file: .jsonl, .json or .csv with inputs/outputs(/reference_outputs/label)."),
|
|
167
|
+
judge: list[str] = typer.Option(..., "--judge", "-j", help=JUDGE_HELP),
|
|
168
|
+
runs: int = typer.Option(3, "--runs", "-n", min=1, help="How many times to ask each judge about each case."),
|
|
169
|
+
rubric: str = typer.Option("correctness", "--rubric", "-r", help="Built-in rubric name (correctness, quality) or a prompt file for LLM judges."),
|
|
170
|
+
adaptive: bool = typer.Option(False, "--adaptive", help="Ask twice everywhere, then spend the remaining runs only on cases where the two disagreed. Same numbers where they matter, far fewer calls."),
|
|
171
|
+
stabilize: int = typer.Option(0, "--stabilize", help="Also measure each judge wrapped in a K-sample majority vote (the fix), next to the raw judge."),
|
|
172
|
+
pass_mark: Optional[float] = typer.Option(None, "--pass-mark", help="Your eval's pass mark on the aggregate score (0.8 = 80% of cases pass). Adds the gate false-alarm section."),
|
|
173
|
+
runs_in_ci: int = typer.Option(1, "--runs-in-ci", min=1, help="How many runs per case your CI gate uses (majority vote). For --pass-mark."),
|
|
174
|
+
concurrency: int = typer.Option(8, "--concurrency", "-c", min=1),
|
|
175
|
+
fresh: bool = typer.Option(False, "--fresh", help="Ignore the cache and make new calls (use this to measure drift over time)."),
|
|
176
|
+
out_dir: Path = typer.Option(DEFAULT_DIR, "--out-dir", help="Where runs and the cache live."),
|
|
177
|
+
html: str = typer.Option("report.html", "--html", help="Write the HTML report here (pass 'none' to skip)."),
|
|
178
|
+
markdown: Optional[Path] = typer.Option(None, "--markdown", help="Also write the card as markdown (what the GitHub Action posts on a PR)."),
|
|
179
|
+
json_out: Optional[Path] = typer.Option(None, "--json", help="Also write the JSON report here."),
|
|
180
|
+
max_flip_rate: Optional[float] = typer.Option(None, "--max-flip-rate", help="CI gate: exit 1 if any judge flipped on more than this share of cases (0.1 = 10%). Depends on --runs."),
|
|
181
|
+
min_self_agreement: Optional[float] = typer.Option(None, "--min-self-agreement", help="CI gate: exit 1 if the chance two runs of a judge agree is below this (0.95 = 95%). Independent of --runs."),
|
|
182
|
+
min_agreement: Optional[float] = typer.Option(None, "--min-agreement", help="CI gate: exit 1 if Krippendorff's alpha between judges is below this."),
|
|
183
|
+
top: int = typer.Option(10, "--top", help="How many flaky cases to list."),
|
|
184
|
+
temperature: Optional[float] = typer.Option(None, "--temperature", help="LLM judges only. Leave unset to use the model default."),
|
|
185
|
+
reasoning_effort: Optional[str] = typer.Option(None, "--reasoning-effort", help="OpenAI reasoning models only (e.g. low, medium)."),
|
|
186
|
+
max_tokens: int = typer.Option(1024, "--max-tokens"),
|
|
187
|
+
label: Optional[str] = typer.Option(None, "--label", help="A name for this run (shows up in compare)."),
|
|
188
|
+
title: str = typer.Option("mistrial report", "--title"),
|
|
189
|
+
quiet: bool = typer.Option(False, "--quiet", "-q"),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Run the judges over the cases and print the reliability card."""
|
|
192
|
+
code = _run_and_report(
|
|
193
|
+
cases, judge, runs, rubric, concurrency, fresh, out_dir, _html_path(html), json_out, max_flip_rate, min_agreement, min_self_agreement, top, temperature, reasoning_effort, max_tokens, label, quiet, title,
|
|
194
|
+
adaptive=adaptive, stabilize=stabilize, pass_mark=pass_mark, runs_in_ci=runs_in_ci, markdown=markdown,
|
|
195
|
+
)
|
|
196
|
+
raise typer.Exit(code)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command("demo")
|
|
200
|
+
def demo_cmd(
|
|
201
|
+
runs: int = typer.Option(5, "--runs", "-n", min=1),
|
|
202
|
+
html: str = typer.Option("mistrial-demo.html", "--html", help="Write the HTML report here (pass 'none' to skip)."),
|
|
203
|
+
out_dir: Path = typer.Option(DEFAULT_DIR, "--out-dir"),
|
|
204
|
+
stabilize: int = typer.Option(0, "--stabilize", help="Also show each demo judge wrapped in a K-sample majority vote."),
|
|
205
|
+
pass_mark: Optional[float] = typer.Option(None, "--pass-mark", help="Show the gate false-alarm section at this pass mark (try 0.8)."),
|
|
206
|
+
adaptive: bool = typer.Option(False, "--adaptive"),
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Try mistrial in ten seconds with three fake judges and twenty bundled cases. No API keys."""
|
|
209
|
+
console.print("[dim]Demo: three fake judges built to be flaky, lenient and steady on purpose, so the card has something to show. Nothing here is broken.[/dim]")
|
|
210
|
+
with resources.as_file(resources.files("mistrial").joinpath("data/demo_cases.jsonl")) as p:
|
|
211
|
+
code = _run_and_report(
|
|
212
|
+
Path(p), ["demo", "demo:flaky", "demo:lenient"], runs, "correctness", 8, True, out_dir, _html_path(html), None, None, None, None, 10, None, None, 1024, "demo", False, "mistrial demo",
|
|
213
|
+
adaptive=adaptive, stabilize=stabilize, pass_mark=pass_mark,
|
|
214
|
+
)
|
|
215
|
+
console.print("[dim]These are fake judges. Point `mistrial run` at your own cases with --judge openai:<model>, --judge ollama:<model> or --judge module:function.[/dim]")
|
|
216
|
+
raise typer.Exit(code)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command("compare")
|
|
220
|
+
def compare_cmd(
|
|
221
|
+
old: Path = typer.Argument(..., help="An earlier run (.mistrial/runs/<stamp>.json)."),
|
|
222
|
+
new: Path = typer.Argument(..., help="A later run of the same cases and judges."),
|
|
223
|
+
json_out: Optional[Path] = typer.Option(None, "--json"),
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Drift: which cases did each judge change its mind on between two runs?"""
|
|
226
|
+
o = json.loads(old.read_text())["report"]
|
|
227
|
+
n = json.loads(new.read_text())["report"]
|
|
228
|
+
cmp = compare(o, n)
|
|
229
|
+
print_compare(cmp, console)
|
|
230
|
+
if json_out:
|
|
231
|
+
json_out.write_text(json.dumps(cmp, indent=2, default=str))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command("report")
|
|
235
|
+
def report_cmd(
|
|
236
|
+
run_file: Path = typer.Argument(..., help="A saved run (.mistrial/runs/<stamp>.json)."),
|
|
237
|
+
html: Optional[Path] = typer.Option(Path("report.html"), "--html"),
|
|
238
|
+
markdown: Optional[Path] = typer.Option(None, "--markdown"),
|
|
239
|
+
pass_mark: Optional[float] = typer.Option(None, "--pass-mark", help="Add the gate false-alarm section at this pass mark."),
|
|
240
|
+
runs_in_ci: int = typer.Option(1, "--runs-in-ci", min=1),
|
|
241
|
+
top: int = typer.Option(10, "--top"),
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Re-render the card from a saved run (no API calls)."""
|
|
244
|
+
data = json.loads(run_file.read_text())
|
|
245
|
+
result = RunResult.from_dict(data["run"])
|
|
246
|
+
report = analyze(result, top=top, pass_mark=pass_mark, runs_in_ci=runs_in_ci)
|
|
247
|
+
print_report(report, console)
|
|
248
|
+
write_outputs(report, result, html_path=html, json_path=None, markdown_path=markdown)
|
|
249
|
+
if html:
|
|
250
|
+
console.print(f"report → {html}")
|
|
251
|
+
if markdown:
|
|
252
|
+
console.print(f"markdown → {markdown}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command("gate-risk")
|
|
256
|
+
def gate_risk_cmd(
|
|
257
|
+
run_file: Path = typer.Argument(..., help="A saved run."),
|
|
258
|
+
pass_mark: float = typer.Option(..., "--pass-mark", help="The pass mark your CI gate uses on the aggregate score (0.8 = 80% of cases must pass; for 1-5 scores, the mean)."),
|
|
259
|
+
runs_in_ci: int = typer.Option(1, "--runs-in-ci", min=1, help="Runs per case your gate uses (majority vote)."),
|
|
260
|
+
max_false_alarm: Optional[float] = typer.Option(None, "--max-false-alarm", help="Exit 1 if any judge's gate would go red more often than this with nothing changed (0.05 = 5%)."),
|
|
261
|
+
) -> None:
|
|
262
|
+
"""How often would your CI gate go red with nothing changed? And what fixes it."""
|
|
263
|
+
data = json.loads(run_file.read_text())
|
|
264
|
+
result = RunResult.from_dict(data["run"])
|
|
265
|
+
report = analyze(result, top=0, pass_mark=pass_mark, runs_in_ci=runs_in_ci)
|
|
266
|
+
report["flaky_cases"] = []
|
|
267
|
+
print_report(report, console)
|
|
268
|
+
if max_false_alarm is not None:
|
|
269
|
+
bad = [g for g in report["gate"]["judges"] if g["false_alarm"] > max_false_alarm]
|
|
270
|
+
if bad:
|
|
271
|
+
err.print("[red]gate risk too high:[/red] " + "; ".join(f"{g['judge']} {g['false_alarm']:.0%}" for g in bad))
|
|
272
|
+
raise typer.Exit(1)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command("rubric-check")
|
|
276
|
+
def rubric_check_cmd(
|
|
277
|
+
cases: Path = typer.Argument(..., help="Cases file."),
|
|
278
|
+
judge: list[str] = typer.Option(..., "--judge", "-j", help="LLM or demo judges (functions have no rubric to reword)."),
|
|
279
|
+
rubric: str = typer.Option("correctness", "--rubric", "-r", help="Built-in rubric (has rewordings and a reversed form) or a prompt file (bring --variant files)."),
|
|
280
|
+
variant: list[Path] = typer.Option([], "--variant", help="Extra rubric wordings as prompt files, repeatable."),
|
|
281
|
+
reversal: bool = typer.Option(True, "--reversal/--no-reversal", help="Also ask with the criterion flipped and check the verdicts invert."),
|
|
282
|
+
runs: int = typer.Option(1, "--runs", "-n", min=1, help="Runs per wording; the majority is used."),
|
|
283
|
+
max_changed: Optional[float] = typer.Option(None, "--max-changed", help="Exit 1 if rewording changes more than this share of verdicts (0.1 = 10%)."),
|
|
284
|
+
min_inversion: Optional[float] = typer.Option(None, "--min-inversion", help="Exit 1 if fewer than this share of verdicts invert under the reversed rubric (0.9 = 90%)."),
|
|
285
|
+
concurrency: int = typer.Option(8, "--concurrency", "-c", min=1),
|
|
286
|
+
fresh: bool = typer.Option(False, "--fresh"),
|
|
287
|
+
out_dir: Path = typer.Option(DEFAULT_DIR, "--out-dir"),
|
|
288
|
+
json_out: Optional[Path] = typer.Option(None, "--json"),
|
|
289
|
+
temperature: Optional[float] = typer.Option(None, "--temperature"),
|
|
290
|
+
reasoning_effort: Optional[str] = typer.Option(None, "--reasoning-effort"),
|
|
291
|
+
max_tokens: int = typer.Option(1024, "--max-tokens"),
|
|
292
|
+
quiet: bool = typer.Option(False, "--quiet", "-q"),
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Does the verdict depend on the wording of the rubric? Rewordings with the same meaning, plus the criterion flipped."""
|
|
295
|
+
cs = load_cases(cases)
|
|
296
|
+
cache = Cache(out_dir / "cache.sqlite", enabled=not fresh)
|
|
297
|
+
bar, tick = _progress(quiet, 1)
|
|
298
|
+
try:
|
|
299
|
+
with bar:
|
|
300
|
+
out = rubric_check(cs, judge, rubric=rubric, variant_files=variant, reversal=reversal, runs=runs, concurrency=concurrency, cache=cache, progress=tick, temperature=temperature, reasoning_effort=reasoning_effort, max_tokens=max_tokens)
|
|
301
|
+
except ValueError as e:
|
|
302
|
+
err.print(f"[red]{e}[/red]")
|
|
303
|
+
raise typer.Exit(2)
|
|
304
|
+
if not quiet:
|
|
305
|
+
print_rubric_check(out, console)
|
|
306
|
+
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
307
|
+
saved = out_dir / "runs" / f"rubric-check-{stamp}.json"
|
|
308
|
+
saved.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
saved.write_text(json.dumps(out, indent=2, default=str))
|
|
310
|
+
if json_out:
|
|
311
|
+
json_out.write_text(json.dumps(out, indent=2, default=str))
|
|
312
|
+
if not quiet:
|
|
313
|
+
console.print(f"saved → {saved}")
|
|
314
|
+
failed = []
|
|
315
|
+
for j in out["judges"]:
|
|
316
|
+
s, r = j.get("sensitivity"), j.get("reversal")
|
|
317
|
+
if max_changed is not None and s and s["changed_rate"] > max_changed:
|
|
318
|
+
failed.append(f"{j['judge']} rewording changed {s['changed_rate']:.0%} of verdicts > {max_changed:.0%}")
|
|
319
|
+
if min_inversion is not None and r and r["inversion_rate"] < min_inversion:
|
|
320
|
+
failed.append(f"{j['judge']} only {r['inversion_rate']:.0%} of verdicts inverted < {min_inversion:.0%}")
|
|
321
|
+
if failed:
|
|
322
|
+
err.print("[red]gate failed:[/red] " + "; ".join(failed))
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command("migrate")
|
|
327
|
+
def migrate_cmd(
|
|
328
|
+
old_run: Path = typer.Argument(..., help="A saved run with the old judge (and the new one, if both ran together)."),
|
|
329
|
+
new_run: Optional[Path] = typer.Argument(None, help="A saved run with the new judge on the same cases, if separate."),
|
|
330
|
+
old_judge: Optional[str] = typer.Option(None, "--old-judge", help="Judge name in the old run (default: first judge)."),
|
|
331
|
+
new_judge: Optional[str] = typer.Option(None, "--new-judge", help="Judge name in the new run (default: second judge of the same run, or first of the new run)."),
|
|
332
|
+
pass_mark: Optional[float] = typer.Option(None, "--pass-mark", help="Your pass mark on the aggregate score under the old judge; printed translated for the new one."),
|
|
333
|
+
threshold: Optional[float] = typer.Option(None, "--threshold", help="A per-case score cutoff (1-5 rubrics) under the old judge; translated by quantile."),
|
|
334
|
+
json_out: Optional[Path] = typer.Option(None, "--json"),
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Switching judge models? See what moved and what your pass mark becomes on the new judge."""
|
|
337
|
+
old = json.loads(old_run.read_text())
|
|
338
|
+
new = json.loads(new_run.read_text()) if new_run else None
|
|
339
|
+
try:
|
|
340
|
+
out = migrate(old, new, old_judge, new_judge, pass_mark, threshold)
|
|
341
|
+
except ValueError as e:
|
|
342
|
+
err.print(f"[red]{e}[/red]")
|
|
343
|
+
raise typer.Exit(2)
|
|
344
|
+
print_migrate(out, console)
|
|
345
|
+
if json_out:
|
|
346
|
+
json_out.write_text(json.dumps(out, indent=2, default=str))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
sentinel_app = typer.Typer(help="Catch a judge that changed under you. Freeze a baseline on anchor cases, then check against it on a schedule.", no_args_is_help=True)
|
|
350
|
+
app.add_typer(sentinel_app, name="sentinel")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@sentinel_app.command("baseline")
|
|
354
|
+
def sentinel_baseline_cmd(
|
|
355
|
+
run_file: Path = typer.Argument(..., help="A saved run on your anchor cases (small, labeled, never changes)."),
|
|
356
|
+
out: Path = typer.Option(Path(".mistrial/baseline.json"), "--out"),
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Freeze a run as the baseline the sentinel compares against."""
|
|
359
|
+
data = json.loads(run_file.read_text())
|
|
360
|
+
base = sentinel_baseline(data)
|
|
361
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
out.write_text(json.dumps(base, indent=2, default=str))
|
|
363
|
+
console.print(f"baseline frozen → {out} · {base['cases']} anchors · judges: {', '.join(base['judges'])}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@sentinel_app.command("check")
|
|
367
|
+
def sentinel_check_cmd(
|
|
368
|
+
cases: Path = typer.Argument(..., help="The same anchor cases the baseline was frozen on."),
|
|
369
|
+
judge: list[str] = typer.Option(..., "--judge", "-j", help=JUDGE_HELP),
|
|
370
|
+
baseline: Path = typer.Option(Path(".mistrial/baseline.json"), "--baseline"),
|
|
371
|
+
runs: int = typer.Option(3, "--runs", "-n", min=1),
|
|
372
|
+
rubric: str = typer.Option("correctness", "--rubric", "-r"),
|
|
373
|
+
max_changed: float = typer.Option(0.10, "--max-changed", help="JUDGE_DRIFT when more than this share of anchor verdicts changed."),
|
|
374
|
+
max_kappa_drop: float = typer.Option(0.15, "--max-kappa-drop", help="JUDGE_DRIFT when kappa vs human labels fell by more than this."),
|
|
375
|
+
concurrency: int = typer.Option(8, "--concurrency", "-c", min=1),
|
|
376
|
+
out_dir: Path = typer.Option(DEFAULT_DIR, "--out-dir"),
|
|
377
|
+
json_out: Optional[Path] = typer.Option(None, "--json"),
|
|
378
|
+
temperature: Optional[float] = typer.Option(None, "--temperature"),
|
|
379
|
+
reasoning_effort: Optional[str] = typer.Option(None, "--reasoning-effort"),
|
|
380
|
+
max_tokens: int = typer.Option(1024, "--max-tokens"),
|
|
381
|
+
quiet: bool = typer.Option(False, "--quiet", "-q"),
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Re-judge the anchors fresh and compare to the baseline. Exit 0 STABLE, 2 JUDGE_DRIFT, 3 anchors changed."""
|
|
384
|
+
base = json.loads(baseline.read_text())
|
|
385
|
+
cs = load_cases(cases)
|
|
386
|
+
judges = _build_judges(judge, rubric, temperature, reasoning_effort, max_tokens)
|
|
387
|
+
cache = Cache(out_dir / "cache.sqlite", enabled=False) # always fresh: the point is to see today's judge
|
|
388
|
+
bar, tick = _progress(quiet, len(cs) * len(judges) * runs)
|
|
389
|
+
with bar:
|
|
390
|
+
result = asyncio.run(run(cs, judges, runs=runs, concurrency=concurrency, cache=cache, progress=tick, rubric=rubric, label="sentinel"))
|
|
391
|
+
report = analyze(result, top=0)
|
|
392
|
+
out = sentinel_check(base, report, cs, max_changed=max_changed, max_kappa_drop=max_kappa_drop)
|
|
393
|
+
if not quiet:
|
|
394
|
+
print_sentinel(out, console)
|
|
395
|
+
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
396
|
+
saved = out_dir / "runs" / f"sentinel-{stamp}.json"
|
|
397
|
+
write_outputs(report, result, html_path=None, json_path=saved)
|
|
398
|
+
if json_out:
|
|
399
|
+
json_out.write_text(json.dumps(out, indent=2, default=str))
|
|
400
|
+
raise typer.Exit({"STABLE": 0, "JUDGE_DRIFT": 2, "ANCHORS_CHANGED": 3}[out["verdict"]])
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.command("escalate")
|
|
404
|
+
def escalate_cmd(
|
|
405
|
+
run_file: Path = typer.Argument(..., help="A saved run."),
|
|
406
|
+
out: Path = typer.Option(Path("needs-human.csv"), "--out", help="CSV for a reviewer: one row per case, with a column for their verdict."),
|
|
407
|
+
min_disagreement: float = typer.Option(0.0, "--min-disagreement", help="Only cases with pooled disagreement above this."),
|
|
408
|
+
misses: bool = typer.Option(True, "--misses/--no-misses", help="Include cases where a judge missed the human label."),
|
|
409
|
+
langsmith_dataset: Optional[str] = typer.Option(None, "--langsmith-dataset", help="Also push the cases to this LangSmith dataset (needs `pip install langsmith` and LANGSMITH_API_KEY)."),
|
|
410
|
+
) -> None:
|
|
411
|
+
"""The cases a human should look at: judges disagreed, flipped, or missed the label."""
|
|
412
|
+
data = json.loads(run_file.read_text())
|
|
413
|
+
report = data["report"]
|
|
414
|
+
rows = escalation(report, min_disagreement=min_disagreement, include_misses=misses)
|
|
415
|
+
names = [j["name"] for j in report["judges"]]
|
|
416
|
+
write_escalation_csv(rows, out, names)
|
|
417
|
+
console.print(f"{len(rows)} of {report['meta']['cases']} cases need a human → {out}")
|
|
418
|
+
for r in rows[:10]:
|
|
419
|
+
console.print(f" {r['case_id']}: {r['reason']}" + (f" ({r['disagreement']:.0%} disagreement)" if r["disagreement"] else ""))
|
|
420
|
+
if len(rows) > 10:
|
|
421
|
+
console.print(f" … and {len(rows) - 10} more")
|
|
422
|
+
if langsmith_dataset:
|
|
423
|
+
try:
|
|
424
|
+
info = push_to_langsmith(rows, langsmith_dataset)
|
|
425
|
+
except Exception as e:
|
|
426
|
+
err.print(f"[red]LangSmith push failed:[/red] {e}")
|
|
427
|
+
raise typer.Exit(2)
|
|
428
|
+
console.print(f"pushed {info['examples']} examples to LangSmith dataset {info['dataset']} ({info['id']})")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@app.command("rubrics")
|
|
432
|
+
def rubrics_cmd() -> None:
|
|
433
|
+
"""List the built-in rubrics."""
|
|
434
|
+
from .prompts import RUBRIC_VARIANTS, REVERSED_RUBRICS
|
|
435
|
+
|
|
436
|
+
for name, r in RUBRICS.items():
|
|
437
|
+
extras = f" ({len(RUBRIC_VARIANTS.get(name, []))} rewordings{', reversed form' if name in REVERSED_RUBRICS else ''})"
|
|
438
|
+
console.print(f"[bold]{name}[/bold] {r['description']}" + (" (needs reference_outputs)" if r["needs_reference"] == "true" else "") + extras)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@app.command("providers")
|
|
442
|
+
def providers_cmd() -> None:
|
|
443
|
+
"""List the LLM providers, the env var that holds each key, and the base URL."""
|
|
444
|
+
console.print("[bold]anthropic[/bold] ANTHROPIC_API_KEY (native messages API)")
|
|
445
|
+
for name, (url, envs) in PROVIDERS.items():
|
|
446
|
+
key = " or ".join(envs) if envs else "no key"
|
|
447
|
+
where = url or "set MISTRIAL_BASE_URL"
|
|
448
|
+
console.print(f"[bold]{name}[/bold] {key} {where}")
|
|
449
|
+
console.print("[dim]<PROVIDER>_BASE_URL overrides a base URL. All of these use --judge <provider>:<model>.[/dim]")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
if __name__ == "__main__":
|
|
453
|
+
app()
|