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.
- promptcheckup/__init__.py +7 -0
- promptcheckup/checkup/__init__.py +62 -0
- promptcheckup/checkup/checkup.py +336 -0
- promptcheckup/checkup/evaluate.py +264 -0
- promptcheckup/checkup/evolve.py +178 -0
- promptcheckup/checkup/failures.py +65 -0
- promptcheckup/checkup/ledger.py +82 -0
- promptcheckup/checkup/report.py +390 -0
- promptcheckup/checkup/snapshot.py +81 -0
- promptcheckup/cli/__init__.py +5 -0
- promptcheckup/cli/checkup.py +107 -0
- promptcheckup/cli/config.py +218 -0
- promptcheckup/cli/doctor.py +316 -0
- promptcheckup/cli/evolve.py +93 -0
- promptcheckup/cli/gate.py +117 -0
- promptcheckup/cli/init.py +42 -0
- promptcheckup/cli/main.py +91 -0
- promptcheckup/cli/project.py +103 -0
- promptcheckup/cli/prompts.py +91 -0
- promptcheckup/cli/templates/example.jsonl +3 -0
- promptcheckup/cli/templates/promptcheckup-checkup.yml +75 -0
- promptcheckup/cli/templates/promptcheckup-gate.yml +35 -0
- promptcheckup/cli/templates/promptcheckup.toml +34 -0
- promptcheckup/data/__init__.py +6 -0
- promptcheckup/data/cases.py +113 -0
- promptcheckup/data/record.py +56 -0
- promptcheckup/errors.py +42 -0
- promptcheckup/llm/__init__.py +39 -0
- promptcheckup/llm/client.py +162 -0
- promptcheckup/llm/grade.py +36 -0
- promptcheckup/llm/judge.py +178 -0
- promptcheckup/llm/patterns.py +50 -0
- promptcheckup/llm/prompting.py +66 -0
- promptcheckup/llm/propose.py +94 -0
- promptcheckup/llm/starter.py +42 -0
- promptcheckup/runner/__init__.py +28 -0
- promptcheckup/runner/agents.py +101 -0
- promptcheckup/runner/replay.py +174 -0
- promptcheckup/stats/__init__.py +5 -0
- promptcheckup/stats/gate.py +175 -0
- promptcheckup/testing.py +102 -0
- promptcheckup-1.0.0.dist-info/METADATA +291 -0
- promptcheckup-1.0.0.dist-info/RECORD +46 -0
- promptcheckup-1.0.0.dist-info/WHEEL +4 -0
- promptcheckup-1.0.0.dist-info/entry_points.txt +2 -0
- promptcheckup-1.0.0.dist-info/licenses/LICENSE +202 -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)}
|