gonogo-eval 0.1.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.
- gonogo/__init__.py +44 -0
- gonogo/cases.py +103 -0
- gonogo/decide.py +165 -0
- gonogo/evaluate.py +83 -0
- gonogo/report.py +368 -0
- gonogo/scoring.py +220 -0
- gonogo/stats.py +219 -0
- gonogo_eval-0.1.0.dist-info/METADATA +179 -0
- gonogo_eval-0.1.0.dist-info/RECORD +11 -0
- gonogo_eval-0.1.0.dist-info/WHEEL +4 -0
- gonogo_eval-0.1.0.dist-info/licenses/LICENSE +21 -0
gonogo/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""gonogo -- decide whether an agent is good enough to ship.
|
|
2
|
+
|
|
3
|
+
Not an eval framework. A decision harness for pilot-scale case sets, where the
|
|
4
|
+
honest answer is often "not enough evidence yet" and sometimes "don't automate
|
|
5
|
+
this at all".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cases import Case, CaseResult, Prediction
|
|
9
|
+
from .decide import Decision, Verdict, decide
|
|
10
|
+
from .evaluate import evaluate
|
|
11
|
+
from .report import Report
|
|
12
|
+
from .scoring import (
|
|
13
|
+
JudgeValidation,
|
|
14
|
+
exact,
|
|
15
|
+
fields,
|
|
16
|
+
judge,
|
|
17
|
+
judge_agreement,
|
|
18
|
+
numeric,
|
|
19
|
+
set_f1,
|
|
20
|
+
validate_judge,
|
|
21
|
+
)
|
|
22
|
+
from .stats import (
|
|
23
|
+
Interval,
|
|
24
|
+
OperatingPoint,
|
|
25
|
+
best_operating_point,
|
|
26
|
+
expected_calibration_error,
|
|
27
|
+
reliability_table,
|
|
28
|
+
required_n,
|
|
29
|
+
risk_coverage,
|
|
30
|
+
wilson,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Case", "CaseResult", "Prediction",
|
|
37
|
+
"Decision", "Verdict", "decide",
|
|
38
|
+
"evaluate", "Report",
|
|
39
|
+
"exact", "fields", "judge", "judge_agreement", "numeric", "set_f1",
|
|
40
|
+
"JudgeValidation", "validate_judge",
|
|
41
|
+
"Interval", "OperatingPoint", "best_operating_point",
|
|
42
|
+
"expected_calibration_error", "reliability_table", "required_n",
|
|
43
|
+
"risk_coverage", "wilson",
|
|
44
|
+
]
|
gonogo/cases.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Real cases, loaded from disk.
|
|
2
|
+
|
|
3
|
+
A case set is the pilot's ground truth. It is deliberately a plain JSONL file
|
|
4
|
+
so a domain expert can build and audit one in a spreadsheet without touching
|
|
5
|
+
Python.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Case:
|
|
18
|
+
"""One real case: an input, the expected output, and whatever context helps."""
|
|
19
|
+
|
|
20
|
+
input: Any
|
|
21
|
+
expected: Any
|
|
22
|
+
id: str | None = None
|
|
23
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_jsonl(cls, path: str | Path) -> list["Case"]:
|
|
27
|
+
"""Load cases from a JSONL file with `input` and `expected` keys.
|
|
28
|
+
|
|
29
|
+
Any other keys land in `metadata`, so you can slice a report by
|
|
30
|
+
customer, document type, or difficulty later.
|
|
31
|
+
"""
|
|
32
|
+
path = Path(path)
|
|
33
|
+
cases: list[Case] = []
|
|
34
|
+
with path.open(encoding="utf-8") as fh:
|
|
35
|
+
for lineno, line in enumerate(fh, 1):
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if not line or line.startswith("//"):
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
row = json.loads(line)
|
|
41
|
+
except json.JSONDecodeError as exc:
|
|
42
|
+
raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
|
|
43
|
+
if not isinstance(row, dict):
|
|
44
|
+
raise ValueError(f"{path}:{lineno}: each line must be a JSON object")
|
|
45
|
+
for required in ("input", "expected"):
|
|
46
|
+
if required not in row:
|
|
47
|
+
raise ValueError(f"{path}:{lineno}: missing required key {required!r}")
|
|
48
|
+
cases.append(cls(
|
|
49
|
+
input=row["input"],
|
|
50
|
+
expected=row["expected"],
|
|
51
|
+
id=row.get("id") or f"case-{lineno}",
|
|
52
|
+
metadata={k: v for k, v in row.items() if k not in ("input", "expected", "id")},
|
|
53
|
+
))
|
|
54
|
+
if not cases:
|
|
55
|
+
raise ValueError(f"{path}: no cases found")
|
|
56
|
+
return cases
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def to_jsonl(cases: list["Case"], path: str | Path) -> None:
|
|
60
|
+
with Path(path).open("w", encoding="utf-8") as fh:
|
|
61
|
+
for c in cases:
|
|
62
|
+
row = {"id": c.id, "input": c.input, "expected": c.expected, **c.metadata}
|
|
63
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Prediction:
|
|
68
|
+
"""What the agent returned for one case.
|
|
69
|
+
|
|
70
|
+
`confidence` is optional but it is what unlocks selective automation; without
|
|
71
|
+
it the harness can only report an overall pass rate.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
output: Any
|
|
75
|
+
confidence: float | None = None
|
|
76
|
+
abstained: bool = False
|
|
77
|
+
error: str | None = None
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
if self.confidence is not None and not 0.0 <= self.confidence <= 1.0:
|
|
81
|
+
raise ValueError(f"confidence must be in [0, 1], got {self.confidence}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class CaseResult:
|
|
86
|
+
"""A scored case: what was expected, what came back, and whether it passed."""
|
|
87
|
+
|
|
88
|
+
case: Case
|
|
89
|
+
prediction: Prediction
|
|
90
|
+
passed: bool
|
|
91
|
+
score: float
|
|
92
|
+
detail: str = ""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def confidence(self) -> float:
|
|
96
|
+
# A case with no stated confidence is treated as fully confident, which
|
|
97
|
+
# keeps it in every coverage bucket rather than silently excusing it.
|
|
98
|
+
return 1.0 if self.prediction.confidence is None else self.prediction.confidence
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def iter_batches(cases: list[Case], size: int) -> Iterator[list[Case]]:
|
|
102
|
+
for i in range(0, len(cases), size):
|
|
103
|
+
yield cases[i:i + size]
|
gonogo/decide.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The verdict layer.
|
|
2
|
+
|
|
3
|
+
Every other eval tool stops at a number. This module turns the number into a
|
|
4
|
+
deployment decision, and refuses to when the evidence is too thin.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from .stats import (
|
|
13
|
+
Interval,
|
|
14
|
+
OperatingPoint,
|
|
15
|
+
best_operating_point,
|
|
16
|
+
expected_calibration_error,
|
|
17
|
+
required_n,
|
|
18
|
+
wilson,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Verdict(str, Enum):
|
|
23
|
+
AUTOMATE = "AUTOMATE"
|
|
24
|
+
AUTOMATE_WITH_REVIEW = "AUTOMATE WITH REVIEW"
|
|
25
|
+
ASSIST_ONLY = "ASSIST ONLY"
|
|
26
|
+
DO_NOT_AUTOMATE = "DO NOT AUTOMATE"
|
|
27
|
+
INSUFFICIENT_EVIDENCE = "INSUFFICIENT EVIDENCE"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Above this, miscalibrated confidence makes an abstention threshold meaningless.
|
|
31
|
+
ECE_UNUSABLE = 0.15
|
|
32
|
+
# An operating point covering less than this isn't worth the plumbing.
|
|
33
|
+
MIN_USEFUL_COVERAGE = 0.25
|
|
34
|
+
# Below this pass rate, tuning won't save it.
|
|
35
|
+
ASSIST_FLOOR = 0.50
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Decision:
|
|
40
|
+
verdict: Verdict
|
|
41
|
+
reason: str
|
|
42
|
+
pass_rate: Interval
|
|
43
|
+
target: float
|
|
44
|
+
operating_point: OperatingPoint | None = None
|
|
45
|
+
calibration_error: float = float("nan")
|
|
46
|
+
needed_n: int | None = None
|
|
47
|
+
notes: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def can_automate(self) -> bool:
|
|
51
|
+
return self.verdict in (Verdict.AUTOMATE, Verdict.AUTOMATE_WITH_REVIEW)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decide(
|
|
55
|
+
results: list[tuple[float, bool]],
|
|
56
|
+
target: float = 0.95,
|
|
57
|
+
level: float = 0.95,
|
|
58
|
+
min_coverage: float = MIN_USEFUL_COVERAGE,
|
|
59
|
+
) -> Decision:
|
|
60
|
+
"""Turn per-case (confidence, passed) results into a deployment decision.
|
|
61
|
+
|
|
62
|
+
The ordering is deliberate. We ask "is the whole task good enough?" first,
|
|
63
|
+
then "is some confident subset good enough?", and only then do we consider
|
|
64
|
+
whether the sample is simply too small to say. A tool that reports
|
|
65
|
+
INSUFFICIENT EVIDENCE when it should report DO NOT AUTOMATE is just as
|
|
66
|
+
dishonest as one that reports a bare point estimate.
|
|
67
|
+
"""
|
|
68
|
+
if not 0.0 < target < 1.0:
|
|
69
|
+
raise ValueError(f"target must be in (0, 1), got {target}")
|
|
70
|
+
|
|
71
|
+
n = len(results)
|
|
72
|
+
if n == 0:
|
|
73
|
+
return Decision(
|
|
74
|
+
verdict=Verdict.INSUFFICIENT_EVIDENCE,
|
|
75
|
+
reason="no cases were evaluated",
|
|
76
|
+
pass_rate=wilson(0, 0, level),
|
|
77
|
+
target=target,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
passed = sum(1 for _, p in results if p)
|
|
81
|
+
rate = wilson(passed, n, level)
|
|
82
|
+
ece = expected_calibration_error(results)
|
|
83
|
+
notes: list[str] = []
|
|
84
|
+
|
|
85
|
+
has_confidence = len({c for c, _ in results}) > 1
|
|
86
|
+
if not has_confidence:
|
|
87
|
+
notes.append(
|
|
88
|
+
"Every case reported the same confidence, so no abstention threshold "
|
|
89
|
+
"can be derived. Emit a real per-case confidence to unlock selective automation."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# 1. The whole task clears the bar on its own.
|
|
93
|
+
if rate.low >= target:
|
|
94
|
+
return Decision(
|
|
95
|
+
verdict=Verdict.AUTOMATE,
|
|
96
|
+
reason=(f"pass rate {rate} clears the {target:.0%} target across all cases "
|
|
97
|
+
f"at the {level:.0%} level"),
|
|
98
|
+
pass_rate=rate, target=target, calibration_error=ece, notes=notes,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# 2. A confident subset clears the bar, with the remainder going to a human.
|
|
102
|
+
#
|
|
103
|
+
# Note that a high calibration error does NOT disqualify a threshold. The
|
|
104
|
+
# precision at each cut point is measured directly from the results and
|
|
105
|
+
# carries its own interval, so it stands whether or not the confidence
|
|
106
|
+
# number is on a meaningful scale. Miscalibration only means the threshold
|
|
107
|
+
# value is a cut point rather than a probability -- worth saying out loud,
|
|
108
|
+
# not worth throwing away a working operating point over.
|
|
109
|
+
if has_confidence:
|
|
110
|
+
if ece > ECE_UNUSABLE:
|
|
111
|
+
notes.append(
|
|
112
|
+
f"Calibration error {ece:.2f} exceeds {ECE_UNUSABLE:.2f}: the confidence score "
|
|
113
|
+
f"ranks cases usefully but its scale is not a probability. Treat any threshold "
|
|
114
|
+
f"below as an opaque cut point, and recalibrate before reading it as a percentage."
|
|
115
|
+
)
|
|
116
|
+
point = best_operating_point(results, target, min_coverage, level)
|
|
117
|
+
if point is not None:
|
|
118
|
+
notes.append(
|
|
119
|
+
f"The {point.threshold:.2f} threshold was chosen by searching this same case "
|
|
120
|
+
f"set, so its precision is optimistically biased. Re-measure it on fresh cases "
|
|
121
|
+
f"before relying on it."
|
|
122
|
+
)
|
|
123
|
+
return Decision(
|
|
124
|
+
verdict=Verdict.AUTOMATE_WITH_REVIEW,
|
|
125
|
+
reason=(f"overall pass rate {rate} misses the {target:.0%} target, but "
|
|
126
|
+
f"abstaining below confidence {point.threshold:.2f} reaches "
|
|
127
|
+
f"{point.precision.point:.1%} precision on {point.coverage:.0%} of cases"),
|
|
128
|
+
pass_rate=rate, target=target, operating_point=point,
|
|
129
|
+
calibration_error=ece, notes=notes,
|
|
130
|
+
)
|
|
131
|
+
near = best_operating_point(results, target, 0.0, level)
|
|
132
|
+
if near is not None:
|
|
133
|
+
notes.append(
|
|
134
|
+
f"A threshold of {near.threshold:.2f} would hit the target but only covers "
|
|
135
|
+
f"{near.coverage:.0%} of cases, below the {min_coverage:.0%} floor."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# 3. The point estimate looks good but the sample can't support the claim.
|
|
139
|
+
if rate.point >= target:
|
|
140
|
+
needed = required_n(rate.point, target, level)
|
|
141
|
+
return Decision(
|
|
142
|
+
verdict=Verdict.INSUFFICIENT_EVIDENCE,
|
|
143
|
+
reason=(f"observed {rate.point:.1%} on {n} cases is above the {target:.0%} target, "
|
|
144
|
+
f"but the {level:.0%} interval reaches down to {rate.low:.1%}"),
|
|
145
|
+
pass_rate=rate, target=target, calibration_error=ece, needed_n=needed,
|
|
146
|
+
notes=notes + ([f"At this rate, about {needed} cases would be needed to claim "
|
|
147
|
+
f"{target:.0%}; you have {n}."] if needed else []),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# 4. Too weak to automate, but a human-in-the-loop assist may still pay off.
|
|
151
|
+
if rate.point >= ASSIST_FLOOR:
|
|
152
|
+
return Decision(
|
|
153
|
+
verdict=Verdict.ASSIST_ONLY,
|
|
154
|
+
reason=(f"pass rate {rate} is well short of the {target:.0%} target and no "
|
|
155
|
+
f"confident subset reaches it; useful as a draft-generator, not as "
|
|
156
|
+
f"an unattended step"),
|
|
157
|
+
pass_rate=rate, target=target, calibration_error=ece, notes=notes,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return Decision(
|
|
161
|
+
verdict=Verdict.DO_NOT_AUTOMATE,
|
|
162
|
+
reason=(f"pass rate {rate} is below {ASSIST_FLOOR:.0%}; this workflow is not a fit "
|
|
163
|
+
f"for automation as currently scoped"),
|
|
164
|
+
pass_rate=rate, target=target, calibration_error=ece, notes=notes,
|
|
165
|
+
)
|
gonogo/evaluate.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The runner: point it at your agent and a case file, get a decision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import concurrent.futures as futures
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .cases import Case, CaseResult, Prediction
|
|
9
|
+
from .decide import MIN_USEFUL_COVERAGE, decide
|
|
10
|
+
from .report import Report
|
|
11
|
+
from .scoring import Scorer, exact
|
|
12
|
+
|
|
13
|
+
# An agent is any callable from a Case to its answer. Returning a bare value is
|
|
14
|
+
# fine; return a Prediction when you can also report confidence.
|
|
15
|
+
Agent = Callable[[Case], Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def evaluate(
|
|
19
|
+
agent: Agent,
|
|
20
|
+
cases: list[Case],
|
|
21
|
+
scorer: Scorer | None = None,
|
|
22
|
+
task: str = "task",
|
|
23
|
+
target: float = 0.95,
|
|
24
|
+
level: float = 0.95,
|
|
25
|
+
min_coverage: float = MIN_USEFUL_COVERAGE,
|
|
26
|
+
workers: int = 1,
|
|
27
|
+
) -> Report:
|
|
28
|
+
"""Run `agent` over `cases`, score it, and decide whether it can ship.
|
|
29
|
+
|
|
30
|
+
An agent that raises is recorded as a failed case rather than aborting the
|
|
31
|
+
run: a crash on 3 of 60 cases is a result about reliability, not a reason to
|
|
32
|
+
lose the other 57.
|
|
33
|
+
"""
|
|
34
|
+
if not cases:
|
|
35
|
+
raise ValueError("no cases to evaluate")
|
|
36
|
+
scorer = scorer or exact()
|
|
37
|
+
|
|
38
|
+
if workers > 1:
|
|
39
|
+
with futures.ThreadPoolExecutor(max_workers=workers) as pool:
|
|
40
|
+
predictions = list(pool.map(lambda c: _run_one(agent, c), cases))
|
|
41
|
+
else:
|
|
42
|
+
predictions = [_run_one(agent, c) for c in cases]
|
|
43
|
+
|
|
44
|
+
results: list[CaseResult] = []
|
|
45
|
+
for case, pred in zip(cases, predictions):
|
|
46
|
+
if pred.error is not None:
|
|
47
|
+
results.append(CaseResult(case, pred, passed=False, score=0.0,
|
|
48
|
+
detail=f"agent error: {pred.error}"))
|
|
49
|
+
continue
|
|
50
|
+
if pred.abstained:
|
|
51
|
+
# An explicit abstention is not a correct answer, but it is honest.
|
|
52
|
+
# It lands at zero confidence so thresholding defers it first.
|
|
53
|
+
results.append(CaseResult(
|
|
54
|
+
case, Prediction(pred.output, confidence=0.0, abstained=True),
|
|
55
|
+
passed=False, score=0.0, detail="agent abstained",
|
|
56
|
+
))
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
passed, score, detail = scorer(pred.output, case.expected)
|
|
60
|
+
except Exception as exc: # a broken scorer shouldn't look like a broken agent
|
|
61
|
+
results.append(CaseResult(case, pred, passed=False, score=0.0,
|
|
62
|
+
detail=f"scorer error: {exc!r}"))
|
|
63
|
+
continue
|
|
64
|
+
results.append(CaseResult(case, pred, passed=passed, score=score, detail=detail))
|
|
65
|
+
|
|
66
|
+
decision = decide(
|
|
67
|
+
[(r.confidence, r.passed) for r in results],
|
|
68
|
+
target=target, level=level, min_coverage=min_coverage,
|
|
69
|
+
)
|
|
70
|
+
return Report(task=task, results=results, decision=decision,
|
|
71
|
+
metadata={"scorer": getattr(scorer, "__qualname__", repr(scorer))})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run_one(agent: Agent, case: Case) -> Prediction:
|
|
75
|
+
try:
|
|
76
|
+
raw = agent(case)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return Prediction(output=None, error=f"{type(exc).__name__}: {exc}")
|
|
79
|
+
if isinstance(raw, Prediction):
|
|
80
|
+
return raw
|
|
81
|
+
if isinstance(raw, tuple) and len(raw) == 2 and isinstance(raw[1], (int, float)):
|
|
82
|
+
return Prediction(output=raw[0], confidence=float(raw[1]))
|
|
83
|
+
return Prediction(output=raw)
|