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 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)