promptcheckup 1.0.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.
Files changed (46) hide show
  1. promptcheckup/__init__.py +7 -0
  2. promptcheckup/checkup/__init__.py +62 -0
  3. promptcheckup/checkup/checkup.py +336 -0
  4. promptcheckup/checkup/evaluate.py +264 -0
  5. promptcheckup/checkup/evolve.py +178 -0
  6. promptcheckup/checkup/failures.py +65 -0
  7. promptcheckup/checkup/ledger.py +82 -0
  8. promptcheckup/checkup/report.py +390 -0
  9. promptcheckup/checkup/snapshot.py +81 -0
  10. promptcheckup/cli/__init__.py +5 -0
  11. promptcheckup/cli/checkup.py +107 -0
  12. promptcheckup/cli/config.py +218 -0
  13. promptcheckup/cli/doctor.py +316 -0
  14. promptcheckup/cli/evolve.py +93 -0
  15. promptcheckup/cli/gate.py +117 -0
  16. promptcheckup/cli/init.py +42 -0
  17. promptcheckup/cli/main.py +91 -0
  18. promptcheckup/cli/project.py +103 -0
  19. promptcheckup/cli/prompts.py +91 -0
  20. promptcheckup/cli/templates/example.jsonl +3 -0
  21. promptcheckup/cli/templates/promptcheckup-checkup.yml +75 -0
  22. promptcheckup/cli/templates/promptcheckup-gate.yml +35 -0
  23. promptcheckup/cli/templates/promptcheckup.toml +34 -0
  24. promptcheckup/data/__init__.py +6 -0
  25. promptcheckup/data/cases.py +113 -0
  26. promptcheckup/data/record.py +56 -0
  27. promptcheckup/errors.py +42 -0
  28. promptcheckup/llm/__init__.py +39 -0
  29. promptcheckup/llm/client.py +162 -0
  30. promptcheckup/llm/grade.py +36 -0
  31. promptcheckup/llm/judge.py +178 -0
  32. promptcheckup/llm/patterns.py +50 -0
  33. promptcheckup/llm/prompting.py +66 -0
  34. promptcheckup/llm/propose.py +94 -0
  35. promptcheckup/llm/starter.py +42 -0
  36. promptcheckup/runner/__init__.py +28 -0
  37. promptcheckup/runner/agents.py +101 -0
  38. promptcheckup/runner/replay.py +174 -0
  39. promptcheckup/stats/__init__.py +5 -0
  40. promptcheckup/stats/gate.py +175 -0
  41. promptcheckup/testing.py +102 -0
  42. promptcheckup-1.0.0.dist-info/METADATA +291 -0
  43. promptcheckup-1.0.0.dist-info/RECORD +46 -0
  44. promptcheckup-1.0.0.dist-info/WHEEL +4 -0
  45. promptcheckup-1.0.0.dist-info/entry_points.txt +2 -0
  46. promptcheckup-1.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,7 @@
1
+ """Prove a prompt change made your AI agent better, with paired replay."""
2
+
3
+ from promptcheckup.data import record
4
+ from promptcheckup.stats import Verdict, decide
5
+
6
+ __all__ = ["Verdict", "decide", "record"]
7
+ __version__ = "1.0.0"
@@ -0,0 +1,62 @@
1
+ """Shared evaluation and human-readable reports."""
2
+
3
+ from promptcheckup.checkup.checkup import CheckupResult, checkup, propose_only
4
+ from promptcheckup.checkup.evaluate import (
5
+ Evaluation,
6
+ Scorer,
7
+ Scores,
8
+ evaluate,
9
+ evaluate_replay,
10
+ judge_mode,
11
+ planned_calls,
12
+ redacted_inputs,
13
+ score_mode,
14
+ )
15
+ from promptcheckup.checkup.evolve import (
16
+ EvolveResult,
17
+ Round,
18
+ evolution_cases,
19
+ evolve,
20
+ round_confidence,
21
+ split_cases,
22
+ )
23
+ from promptcheckup.checkup.ledger import false_better_expected, record_trial, trials
24
+ from promptcheckup.checkup.report import checkup_report, evolve_report, gate_report, proposal_report
25
+ from promptcheckup.checkup.snapshot import (
26
+ Snapshot,
27
+ load_latest_snapshot,
28
+ load_snapshot,
29
+ save_snapshot,
30
+ )
31
+
32
+ __all__ = [
33
+ "EvolveResult",
34
+ "Round",
35
+ "evolution_cases",
36
+ "evolve",
37
+ "split_cases",
38
+ "round_confidence",
39
+ "evolve_report",
40
+ "record_trial",
41
+ "trials",
42
+ "false_better_expected",
43
+ "CheckupResult",
44
+ "Snapshot",
45
+ "checkup",
46
+ "checkup_report",
47
+ "evaluate_replay",
48
+ "load_snapshot",
49
+ "load_latest_snapshot",
50
+ "proposal_report",
51
+ "propose_only",
52
+ "save_snapshot",
53
+ "Evaluation",
54
+ "Scorer",
55
+ "Scores",
56
+ "evaluate",
57
+ "gate_report",
58
+ "judge_mode",
59
+ "planned_calls",
60
+ "redacted_inputs",
61
+ "score_mode",
62
+ ]
@@ -0,0 +1,336 @@
1
+ """Weekly check-up and proposal pipelines: named steps over one frozen state and budget.
2
+
3
+ Each step takes the fixed plan and the state so far and returns a new state. A step
4
+ that cannot afford its calls stops the pipeline; the result then says the budget ran
5
+ out, keeps the evidence gathered so far, and never keeps a proposal.
6
+ """
7
+
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field, replace
10
+ from functools import reduce
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from promptcheckup.checkup.evaluate import (
15
+ Evaluation,
16
+ Scorer,
17
+ evaluate,
18
+ evaluate_replay,
19
+ judge_mode,
20
+ redacted_inputs,
21
+ score_mode,
22
+ )
23
+ from promptcheckup.checkup.failures import current_failures
24
+ from promptcheckup.checkup.ledger import record_trial
25
+ from promptcheckup.checkup.snapshot import load_latest_snapshot, load_snapshot, save_snapshot
26
+ from promptcheckup.data import Case, append_cases
27
+ from promptcheckup.errors import errors_as, fallback
28
+ from promptcheckup.llm.patterns import Pattern, failure_patterns
29
+ from promptcheckup.llm.propose import Proposal, ProposalRejected, propose
30
+ from promptcheckup.llm.starter import starter_cases
31
+ from promptcheckup.runner import Agent, AgentError, Cache, Replay, Run, prompt_hash, run_prompts
32
+ from promptcheckup.stats import Verdict
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class CheckupResult:
37
+ starter_created: int
38
+ drift: Evaluation | None
39
+ agent_version_changed: bool
40
+ patterns: list[Pattern]
41
+ proposal: Proposal | None
42
+ proposal_verdict: Verdict | None
43
+ proposal_rejection: str | None
44
+ calls: int
45
+ synthetic_share: float = 0.0
46
+ budget_exhausted: bool = False
47
+ errors: list[Run] = field(default_factory=list)
48
+ prompts_hash: str = ""
49
+ model: str = ""
50
+ drift_checked: bool = True
51
+ snapshot_saved: bool = False
52
+ gated_proposal: Proposal | None = None # audit evidence, never authorization to write files
53
+ drift_baseline: str = "same prompt"
54
+ previous_prompts_hash: str = ""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class _Plan:
59
+ """Everything fixed for one run. `score_fn` selects score mode; otherwise judge mode."""
60
+
61
+ agent: Agent
62
+ prompts: dict[str, str]
63
+ llm: Callable[[str], str]
64
+ directory: Path
65
+ score_fn: Callable[[Any, Any], float] | None = None
66
+ criteria: str = ""
67
+ scorer: Scorer | None = None
68
+ samples: int = 2
69
+ timeout_s: float = 60
70
+ max_calls: int = 400
71
+ min_pairs: int = 20
72
+ cache: Cache | None = None
73
+ agent_version: str = ""
74
+ description: str = ""
75
+ example_input: dict = field(default_factory=dict)
76
+ redact: Callable[[Any], Any] | None = None
77
+ gate_cases: list[Case] | None = None
78
+ confidence: float = 0.95
79
+ trial_command: str | None = None # CLI opts into durable recording immediately after gating
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _State:
84
+ result: CheckupResult
85
+ cases: list[Case]
86
+ current: Replay = field(default_factory=lambda: Replay([], 0, False))
87
+ failures: list[tuple] = field(default_factory=list)
88
+ stopped: bool = False
89
+
90
+
91
+ def checkup(agent, cases, prompts, llm, **settings) -> CheckupResult:
92
+ """Starter cases, current runs, drift, failures, patterns, and a gated proposal.
93
+
94
+ Settings are the fields of `_Plan`: directory (project root), score_fn or criteria,
95
+ samples, timeout_s, max_calls, min_pairs, cache, agent_version, description,
96
+ example_input, redact, and optionally a scorer following evaluate's protocol.
97
+ gate_cases and confidence override only the proposal gate. trial_command opts
98
+ into recording each gate immediately, including rejected and budget-stopped gates.
99
+ """
100
+ plan = _plan(agent, prompts, llm, settings)
101
+ return _run(plan, _start(plan, cases), CHECKUP_STEPS)
102
+
103
+
104
+ def propose_only(agent, cases, prompts, llm, **settings) -> CheckupResult:
105
+ """Current runs, failures, patterns and the gate, without starter cases or drift."""
106
+ if not cases:
107
+ raise ValueError("propose requires existing cases")
108
+ plan = _plan(agent, prompts, llm, settings)
109
+ state = _start(plan, cases)
110
+ return _run(plan, replace(state, result=replace(state.result, drift_checked=False)), PROPOSE)
111
+
112
+
113
+ def _plan(agent, prompts, llm, settings) -> _Plan:
114
+ plan = _Plan(agent, prompts, llm, **settings)
115
+ # Outputs are redacted as they leave the agent: before the cache, snapshots and the LLM.
116
+ return replace(
117
+ plan,
118
+ directory=Path(plan.directory),
119
+ agent=_redacting(agent, plan.redact),
120
+ scorer=_scorer(plan),
121
+ )
122
+
123
+
124
+ def _start(plan, cases) -> _State:
125
+ model = getattr(plan.llm, "model", "")
126
+ result = CheckupResult(
127
+ 0, None, False, [], None, None, None, 0, prompts_hash=prompt_hash(plan.prompts), model=model
128
+ )
129
+ return _State(result, list(cases))
130
+
131
+
132
+ def _run(plan, state, steps) -> CheckupResult:
133
+ final = reduce(lambda done, step: done if done.stopped else step(plan, done), steps, state)
134
+ if final.stopped:
135
+ return replace(final.result, budget_exhausted=True, proposal=None)
136
+ return final.result
137
+
138
+
139
+ # Steps ---------------------------------------------------------------------------------
140
+
141
+
142
+ def _starter(plan, state) -> _State:
143
+ if state.cases:
144
+ return state
145
+ if _remaining(plan, state) < 1:
146
+ return replace(state, stopped=True)
147
+ example = _redacted(plan.redact, plan.example_input)
148
+ generated = starter_cases(plan.llm, plan.prompts, plan.description, example)
149
+ cases = [replace(case, input=_redacted(plan.redact, case.input)) for case in generated]
150
+ append_cases(plan.directory / "cases" / "starter.jsonl", cases)
151
+ return _spend(replace(state, cases=cases), 1, starter_created=len(cases))
152
+
153
+
154
+ def _current(plan, state) -> _State:
155
+ replayed = run_prompts(
156
+ plan.agent,
157
+ state.cases,
158
+ plan.prompts,
159
+ samples=plan.samples,
160
+ timeout_s=plan.timeout_s,
161
+ max_calls=_remaining(plan, state),
162
+ cache=plan.cache,
163
+ )
164
+ share = sum(case.source == "synthetic" for case in state.cases) / len(state.cases)
165
+ errors = [run for run in replayed.runs if run.error is not None]
166
+ state = _spend(state, replayed.calls, synthetic_share=share, errors=errors)
167
+ return replace(state, current=replayed, stopped=replayed.budget_exhausted)
168
+
169
+
170
+ def _drift(plan, state) -> _State:
171
+ state = _compare_snapshot(plan, state)
172
+ if state.stopped:
173
+ return state # an incomplete comparison keeps the previous snapshot
174
+ save_snapshot(plan.directory, state.result.prompts_hash, state.current.runs, plan.agent_version)
175
+ return _spend(state, 0, snapshot_saved=True)
176
+
177
+
178
+ def _compare_snapshot(plan, state) -> _State:
179
+ previous = load_snapshot(plan.directory, state.result.prompts_hash)
180
+ if previous is not None:
181
+ return _compare_with(plan, state, previous)
182
+ latest = load_latest_snapshot(plan.directory, excluding=state.result.prompts_hash)
183
+ if latest is None:
184
+ return state
185
+ previous_hash, previous = latest
186
+ state = _spend(state, 0, drift_baseline="previous prompt", previous_prompts_hash=previous_hash)
187
+ return _compare_with(plan, state, previous)
188
+
189
+
190
+ def _compare_with(plan, state, previous) -> _State:
191
+ ids = {case.id for case in state.cases}
192
+ old = [run for run in previous.runs if run.case_id in ids]
193
+ new = [replace(run, prompt_set="B") for run in state.current.runs]
194
+ drift = evaluate_replay(
195
+ state.cases,
196
+ Replay(old + new, 0, False),
197
+ plan.scorer,
198
+ budget=_remaining(plan, state),
199
+ min_pairs=plan.min_pairs,
200
+ )
201
+ changed = previous.agent_version != plan.agent_version
202
+ state = _spend(state, drift.scores.judge_calls, drift=drift, agent_version_changed=changed)
203
+ return replace(state, stopped=drift.budget_exhausted)
204
+
205
+
206
+ def _failures(plan, state) -> _State:
207
+ judged = plan.score_fn is None
208
+ cost = sum(run.sample == 0 and run.error is None for run in state.current.runs) if judged else 0
209
+ if _remaining(plan, state) < cost:
210
+ return replace(state, stopped=True)
211
+ failures = current_failures(
212
+ _safe_cases(plan, state.cases) if judged else state.cases,
213
+ state.current,
214
+ score_fn=plan.score_fn,
215
+ llm=plan.llm,
216
+ criteria=plan.criteria,
217
+ )
218
+ safe = _safe_failures(plan, state.cases, failures)
219
+ return replace(_spend(state, cost), failures=safe)
220
+
221
+
222
+ def _patterns(plan, state) -> _State:
223
+ cost = 1 if state.failures else 0
224
+ if _remaining(plan, state) < cost:
225
+ return replace(state, stopped=True)
226
+ return _spend(state, cost, patterns=failure_patterns(plan.llm, state.failures))
227
+
228
+
229
+ def _proposal(plan, state) -> _State:
230
+ if not state.failures:
231
+ return state
232
+ if _remaining(plan, state) < 1:
233
+ return replace(state, stopped=True)
234
+ spent = _spend(state, 1)
235
+ # A rejected proposal is a finding for the report, not a failure of the run.
236
+ return fallback(
237
+ lambda: _gate(
238
+ plan, spent, propose(plan.llm, plan.prompts, spent.result.patterns, spent.failures)
239
+ ),
240
+ lambda error: _spend(spent, 0, proposal_rejection=str(error)),
241
+ ProposalRejected,
242
+ )
243
+
244
+
245
+ def _gate(plan, state, proposal) -> _State:
246
+ # Replays current against proposed back-to-back; cached current outputs are free.
247
+ cases = state.cases if plan.gate_cases is None else plan.gate_cases
248
+ result = evaluate(
249
+ plan.agent,
250
+ cases,
251
+ plan.prompts,
252
+ proposal.prompts,
253
+ plan.scorer,
254
+ samples=plan.samples,
255
+ timeout_s=plan.timeout_s,
256
+ max_calls=_remaining(plan, state),
257
+ min_pairs=plan.min_pairs,
258
+ cache=plan.cache,
259
+ confidence=plan.confidence,
260
+ )
261
+ _record_trial(plan, cases, proposal, result)
262
+ gate_errors = [
263
+ run for run in result.errors if plan.gate_cases is not None or run.prompt_set == "B"
264
+ ]
265
+ state = _spend(
266
+ state,
267
+ result.replay.calls + result.scores.judge_calls,
268
+ proposal_verdict=result.verdict,
269
+ gated_proposal=proposal,
270
+ proposal=proposal if result.verdict.decision == "better" else None,
271
+ errors=[*state.result.errors, *gate_errors],
272
+ )
273
+ return replace(state, stopped=result.budget_exhausted)
274
+
275
+
276
+ def _record_trial(plan, cases, proposal, result):
277
+ if plan.trial_command is not None:
278
+ record_trial(
279
+ plan.directory,
280
+ case_ids=[case.id for case in cases],
281
+ prompt_a=prompt_hash(plan.prompts),
282
+ prompt_b=prompt_hash(proposal.prompts),
283
+ decision=result.verdict.decision,
284
+ confidence=plan.confidence,
285
+ command=plan.trial_command,
286
+ )
287
+
288
+
289
+ CHECKUP_STEPS = (_starter, _current, _drift, _failures, _patterns, _proposal)
290
+ PROPOSE = (_current, _failures, _patterns, _proposal)
291
+
292
+
293
+ # Helpers -------------------------------------------------------------------------------
294
+
295
+
296
+ def _remaining(plan, state) -> int:
297
+ return plan.max_calls - state.result.calls
298
+
299
+
300
+ def _spend(state, calls, **fields) -> _State:
301
+ result = replace(state.result, calls=state.result.calls + calls, **fields)
302
+ return replace(state, result=result)
303
+
304
+
305
+ def _scorer(plan) -> Scorer:
306
+ if plan.scorer is not None:
307
+ return plan.scorer
308
+ if plan.score_fn is not None:
309
+ return score_mode(plan.score_fn)
310
+ return redacted_inputs(judge_mode(plan.llm, plan.criteria, cache=plan.cache), plan.redact)
311
+
312
+
313
+ def _redacting(agent, redact) -> Agent:
314
+ if redact is None:
315
+ return agent
316
+ return lambda case, prompts, timeout_s: _redacted(redact, agent(case, prompts, timeout_s))
317
+
318
+
319
+ def _redacted(redact, value):
320
+ if redact is None:
321
+ return value
322
+ with errors_as(AgentError, {Exception: lambda error: f"run.redact failed: {error}"}):
323
+ return redact(value)
324
+
325
+
326
+ def _safe_cases(plan, cases) -> list[Case]:
327
+ return [replace(case, input=_redacted(plan.redact, case.input)) for case in cases]
328
+
329
+
330
+ def _safe_failures(plan, cases, failures) -> list[tuple]:
331
+ # Inputs and error text are redacted before they reach the pattern and proposal calls.
332
+ inputs = {case.id: _redacted(plan.redact, case.input) for case in cases}
333
+ return [
334
+ (key, inputs[key], output, _redacted(plan.redact, reason))
335
+ for key, _, output, reason in failures
336
+ ]
@@ -0,0 +1,264 @@
1
+ """Shared paired evaluation: replay, score, decide, and conservatively downgrade."""
2
+
3
+ import json
4
+ import math
5
+ from collections.abc import Sequence
6
+ from dataclasses import asdict, dataclass, replace
7
+ from statistics import fmean
8
+ from typing import Protocol
9
+
10
+ from promptcheckup.data import Case
11
+ from promptcheckup.errors import errors_as, fallback
12
+ from promptcheckup.llm import SelfCheck, judge_pairs, paired_scores, pairs_from_replay, self_check
13
+ from promptcheckup.runner import AgentError, Replay, Run, prompt_hash, replay
14
+ from promptcheckup.stats import Verdict, decide
15
+
16
+ DOWNGRADES = (
17
+ (
18
+ lambda result: result.budget_exhausted,
19
+ lambda result: "call budget reached; raise run.max_calls or use fewer cases",
20
+ ),
21
+ (
22
+ lambda result: (
23
+ result.scores.self_check is not None and not result.scores.self_check.reliable
24
+ ),
25
+ lambda result: f"judge unreliable: {result.scores.self_check.reason}",
26
+ ),
27
+ )
28
+ JUDGE_CALLS = {"score": lambda n, s: 0, "judge": lambda n, s: 2 * n * (s + 1)}
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Scores:
33
+ per_case: list[tuple[str, float]]
34
+ self_check: SelfCheck | None
35
+ skew_guard: bool
36
+ judge_calls: int = 0
37
+ prompt_tokens: int | None = None
38
+ completion_tokens: int | None = None
39
+ model: str = ""
40
+ budget_exhausted: bool = False
41
+
42
+
43
+ class Scorer(Protocol):
44
+ """Turn a replay into per-case differences, spending at most `budget` further calls."""
45
+
46
+ def __call__(
47
+ self, cases: Sequence[Case], replay: Replay, *, budget: int, min_pairs: int
48
+ ) -> Scores: ...
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Evaluation:
53
+ verdict: Verdict
54
+ scores: Scores
55
+ replay: Replay
56
+ errors: list[Run]
57
+ budget_exhausted: bool
58
+
59
+
60
+ def score_mode(score_fn) -> Scorer:
61
+ """Average B-minus-A scores only over complete, successful sample pairs."""
62
+
63
+ def score(cases, replay, *, budget, min_pairs):
64
+ items = pairs_from_replay(cases, replay)
65
+ differences = [(key, _difference(score_fn, data, a, b)) for key, _, data, a, b in items]
66
+ return Scores(_per_case(differences), None, True)
67
+
68
+ return score
69
+
70
+
71
+ def _difference(score_fn, case_input, a, b) -> float:
72
+ with errors_as(AgentError, {Exception: lambda error: f"scoring.score failed: {error}"}):
73
+ difference = float(score_fn(case_input, b)) - float(score_fn(case_input, a))
74
+ if not math.isfinite(difference):
75
+ raise ValueError("paired differences must be finite numbers")
76
+ return difference
77
+
78
+
79
+ def _per_case(differences) -> list[tuple[str, float]]:
80
+ grouped = {}
81
+ for key, difference in differences:
82
+ grouped.setdefault(key, []).append(difference)
83
+ return [(key, fmean(values)) for key, values in grouped.items()]
84
+
85
+
86
+ def judge_mode(llm, criteria, redact=None, *, cache=None) -> Scorer:
87
+ """Use swapped comparisons and a self-check, optionally cached for a named model."""
88
+
89
+ def score(cases, replay, *, budget, min_pairs):
90
+ safe_cases, safe_replay = _redacted(cases, replay, redact)
91
+ before = _tokens(llm)
92
+ scores = _judged(llm, criteria, cache, safe_cases, safe_replay, budget, min_pairs)
93
+ return replace(
94
+ scores, model=getattr(llm, "model", ""), **_token_delta(before, _tokens(llm))
95
+ )
96
+
97
+ return score
98
+
99
+
100
+ def redacted_inputs(scorer: Scorer, redact) -> Scorer:
101
+ """Redact case inputs before scoring; agent outputs are redacted when they are produced."""
102
+ if redact is None:
103
+ return scorer
104
+
105
+ def score(cases, replay, *, budget, min_pairs):
106
+ with errors_as(AgentError, {Exception: lambda error: f"run.redact failed: {error}"}):
107
+ safe = [replace(case, input=redact(case.input)) for case in cases]
108
+ return scorer(safe, replay, budget=budget, min_pairs=min_pairs)
109
+
110
+ return score
111
+
112
+
113
+ def _judged(llm, criteria, cache, cases, replay, budget, min_pairs) -> Scores:
114
+ items = pairs_from_replay(cases, replay)
115
+ limited = items[: budget // 2] # each pair is judged in both orders
116
+ comparisons = judge_pairs(llm, limited, criteria)
117
+ ids = list(dict.fromkeys(item.case_id for item in comparisons))
118
+ values = list(zip(ids, paired_scores(comparisons), strict=True))
119
+ remaining = budget - 2 * len(limited)
120
+ check = _judge_self_check(llm, criteria, cache, cases, replay, remaining, min_pairs)
121
+ return Scores(
122
+ values,
123
+ check,
124
+ False,
125
+ judge_calls=2 * len(limited) + check.calls,
126
+ budget_exhausted=len(limited) < len(items) or check.budget_exhausted,
127
+ )
128
+
129
+
130
+ def _judge_self_check(llm, criteria, cache, cases, replay, remaining, min_pairs):
131
+ key = _check_key(llm, criteria, cases, replay, min_pairs)
132
+ cached = _read_check(cache, key)
133
+ if cached is not None:
134
+ return replace(cached, calls=0)
135
+ check = self_check(llm, cases, replay, criteria, min_pairs=min_pairs, max_calls=remaining)
136
+ if cache is not None and key and not check.budget_exhausted:
137
+ cache.put(key, "judge-self-check", 0, asdict(check))
138
+ return check
139
+
140
+
141
+ def _check_key(llm, criteria, cases, replay, min_pairs):
142
+ # The verdict depends on who judges, against what, and the A outputs judged.
143
+ judge = {"model": getattr(llm, "model", ""), "base_url": getattr(llm, "base_url", "")}
144
+ if not judge["model"]:
145
+ return "" # an unnamed judge cannot be recognised next time
146
+ evidence = {
147
+ **judge,
148
+ "criteria": criteria,
149
+ "min_pairs": min_pairs,
150
+ "cases": [(case.id, case.input) for case in cases],
151
+ "runs": [
152
+ (run.case_id, run.sample, run.output, run.error)
153
+ for run in replay.runs
154
+ if run.prompt_set == "A"
155
+ ],
156
+ }
157
+ return prompt_hash({"self-check": json.dumps(evidence, sort_keys=True)})
158
+
159
+
160
+ def _read_check(cache, key):
161
+ if cache is None or not key:
162
+ return None
163
+ found, payload = cache.get(key, "judge-self-check", 0)
164
+ if not found:
165
+ return None
166
+ return fallback(
167
+ lambda: _decoded_check(payload), lambda error: None, KeyError, TypeError, ValueError
168
+ )
169
+
170
+
171
+ def _decoded_check(payload):
172
+ data = dict(payload)
173
+ verdict = data.pop("verdict")
174
+ check = SelfCheck(verdict=Verdict(**verdict) if verdict is not None else None, **data)
175
+ if type(check.reliable) is not bool or not 0 <= check.consistency <= 1:
176
+ raise ValueError("damaged self-check cache")
177
+ if check.reliable and check.verdict is None:
178
+ raise ValueError("reliable self-check needs a verdict")
179
+ return check
180
+
181
+
182
+ def _tokens(llm) -> dict:
183
+ usage = getattr(llm, "usage", None)
184
+ return {name: getattr(usage, name, None) for name in ("prompt_tokens", "completion_tokens")}
185
+
186
+
187
+ def _token_delta(before, after) -> dict:
188
+ return {name: None if before[name] is None else after[name] - before[name] for name in before}
189
+
190
+
191
+ def _redacted(cases, replay, redact):
192
+ if redact is None:
193
+ return cases, replay
194
+ with errors_as(AgentError, {Exception: lambda error: f"run.redact failed: {error}"}):
195
+ safe_cases = [replace(case, input=redact(case.input)) for case in cases]
196
+ runs = [replace(run, output=redact(run.output)) for run in replay.runs if run.error is None]
197
+ return safe_cases, replace(replay, runs=runs)
198
+
199
+
200
+ def evaluate(
201
+ agent,
202
+ cases,
203
+ prompts_a,
204
+ prompts_b,
205
+ scorer,
206
+ *,
207
+ samples,
208
+ timeout_s,
209
+ max_calls,
210
+ min_pairs,
211
+ cache,
212
+ confidence=0.95,
213
+ ) -> Evaluation:
214
+ """Evaluate paired evidence, preserving statistics even when a safeguard blocks it."""
215
+ replayed = replay(
216
+ agent,
217
+ cases,
218
+ prompts_a,
219
+ prompts_b,
220
+ samples=samples,
221
+ timeout_s=timeout_s,
222
+ max_calls=max_calls,
223
+ cache=cache,
224
+ )
225
+ return evaluate_replay(
226
+ cases,
227
+ replayed,
228
+ scorer,
229
+ budget=max_calls - replayed.calls,
230
+ min_pairs=min_pairs,
231
+ confidence=confidence,
232
+ )
233
+
234
+
235
+ def evaluate_replay(cases, replayed, scorer, *, budget, min_pairs, confidence=0.95) -> Evaluation:
236
+ """Score existing evidence using the same decision and downgrade rules as evaluate."""
237
+ scores = scorer(cases, replayed, budget=budget, min_pairs=min_pairs)
238
+ verdict = decide(
239
+ [value for _, value in scores.per_case],
240
+ min_pairs=min_pairs,
241
+ skew_guard=scores.skew_guard,
242
+ confidence=confidence,
243
+ )
244
+ result = Evaluation(
245
+ verdict,
246
+ scores,
247
+ replayed,
248
+ [run for run in replayed.runs if run.error is not None],
249
+ replayed.budget_exhausted or scores.budget_exhausted,
250
+ )
251
+ return _downgraded(result)
252
+
253
+
254
+ def _downgraded(result: Evaluation) -> Evaluation:
255
+ for predicate, reason in DOWNGRADES:
256
+ if predicate(result):
257
+ verdict = replace(result.verdict, decision="inconclusive", reason=reason(result))
258
+ return replace(result, verdict=verdict)
259
+ return result
260
+
261
+
262
+ def planned_calls(n_cases, samples, mode) -> dict:
263
+ """Cold-cache agent calls and judge calls, including swaps and the A-vs-A check."""
264
+ return {"agent": 2 * n_cases * samples, "judge": JUDGE_CALLS[mode](n_cases, samples)}