poorjev 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.
poorjev/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """poorjev: the poor man's Jev.
2
+
3
+ A local-first "System One" decision layer. Ask typed questions about some state,
4
+ get typed answers with calibrated confidence, in one pass, with no API key by
5
+ default. The one thing we prove: the confidence is honest.
6
+
7
+ M1 ships the contract (the three primitives + typed answers). Backends (M2/M5)
8
+ and calibration (M4) build on top without ever being able to break schema
9
+ validity, which is structural. See PRD.md.
10
+ """
11
+
12
+ from .primitives import (
13
+ Choice,
14
+ Score,
15
+ Noul,
16
+ ChoiceAnswer,
17
+ ScoreAnswer,
18
+ NoulAnswer,
19
+ )
20
+ from .client import Client
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "Client",
26
+ "Choice",
27
+ "Score",
28
+ "Noul",
29
+ "ChoiceAnswer",
30
+ "ScoreAnswer",
31
+ "NoulAnswer",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,4 @@
1
+ """Pluggable backends. M1 ships none; the local NLI backend lands in M2 and the
2
+ opt-in LLM backend in M5. A backend's whole job is to turn state + a primitive's
3
+ hypotheses into a vector of raw scores; the primitive does the rest.
4
+ """
@@ -0,0 +1,108 @@
1
+ """Local, keyless NLI backend, the hero.
2
+
3
+ One small natural-language-inference model does all three primitives by scoring
4
+ (state, hypothesis) pairs for entailment:
5
+
6
+ - Noul -> P(entailment) of the statement given the state = P(true).
7
+ - Choice -> score each option as a hypothesis, softmax across options.
8
+ - Score -> same, over the ordered levels.
9
+
10
+ Why NLI and not a sampled LLM: it is one forward pass (Jev's speed ballpark, not
11
+ seconds), it is fully local and offline after a one-time ~400MB download, and its
12
+ softmax output is a real probability we can calibrate directly, no sampling tax.
13
+ It is not the smartest option; that is the point of the optional [llm] backend.
14
+ Here, calibration + abstention are what make moderate intelligence safe: the
15
+ model knows when it is unsure and escalates.
16
+
17
+ The heavy imports (torch, transformers) are lazy so importing poorjev, and the
18
+ whole M1 contract, stays dependency-free.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ DEFAULT_MODEL = "MoritzLaurer/deberta-v3-base-zeroshot-v2.0"
24
+
25
+
26
+ class LocalNLIBackend:
27
+ """Scores (premise, hypothesis) pairs and returns P(entailment) for each.
28
+
29
+ This is the only method the Client needs. Everything primitive-specific
30
+ (templating options, normalising across a Choice) lives in the Client so a
31
+ future backend can reuse the same orchestration.
32
+ """
33
+
34
+ def __init__(self, model_name: str = DEFAULT_MODEL, device: str | None = None,
35
+ batch_size: int = 16, max_length: int = 512):
36
+ self.model_name = model_name
37
+ self.batch_size = batch_size
38
+ self.max_length = max_length
39
+ self._device = device
40
+ self._tokenizer = None
41
+ self._model = None
42
+ self._entail_idx: int | None = None
43
+
44
+ # -- lazy load -------------------------------------------------------- #
45
+ def _ensure_loaded(self) -> None:
46
+ if self._model is not None:
47
+ return
48
+ try:
49
+ import torch
50
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
51
+ except ImportError as e: # pragma: no cover - env dependent
52
+ raise ImportError(
53
+ "The local NLI backend needs the 'local' extra. "
54
+ "Install it with: pip install 'poorjev[local]'"
55
+ ) from e
56
+
57
+ if self._device is None:
58
+ if torch.cuda.is_available():
59
+ self._device = "cuda"
60
+ elif getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
61
+ self._device = "mps"
62
+ else:
63
+ self._device = "cpu"
64
+
65
+ try:
66
+ self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
67
+ except Exception: # deberta-v3 fast tokenizer can be finicky; fall back
68
+ self._tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
69
+
70
+ self._model = AutoModelForSequenceClassification.from_pretrained(self.model_name)
71
+ self._model.to(self._device)
72
+ self._model.eval()
73
+ self._entail_idx = self._find_entail_idx(self._model.config)
74
+
75
+ @staticmethod
76
+ def _find_entail_idx(config) -> int:
77
+ id2label = getattr(config, "id2label", None) or {}
78
+ for idx, label in id2label.items():
79
+ name = str(label).lower()
80
+ if "entail" in name and "not" not in name and "non" not in name:
81
+ return int(idx)
82
+ # Fallback: many NLI heads put entailment first or last; default to 0.
83
+ return 0
84
+
85
+ # -- the one method the Client calls ---------------------------------- #
86
+ def entail_probs(self, pairs: list[tuple[str, str]]) -> list[float]:
87
+ """Return P(entailment) in [0, 1] for each (premise, hypothesis) pair,
88
+ computed in batched forward passes (the single-pass promise)."""
89
+ if not pairs:
90
+ return []
91
+ self._ensure_loaded()
92
+ import torch
93
+
94
+ out: list[float] = []
95
+ for start in range(0, len(pairs), self.batch_size):
96
+ chunk = pairs[start:start + self.batch_size]
97
+ premises = [p for p, _ in chunk]
98
+ hypotheses = [h for _, h in chunk]
99
+ enc = self._tokenizer(
100
+ premises, hypotheses,
101
+ return_tensors="pt", padding=True,
102
+ truncation=True, max_length=self.max_length,
103
+ ).to(self._device)
104
+ with torch.no_grad():
105
+ logits = self._model(**enc).logits
106
+ probs = torch.softmax(logits, dim=-1)[:, self._entail_idx]
107
+ out.extend(probs.detach().cpu().tolist())
108
+ return out
poorjev/calibration.py ADDED
@@ -0,0 +1,164 @@
1
+ """Calibration: the one thing poorjev gets provably right.
2
+
3
+ Two pieces:
4
+
5
+ 1. Temperature scaling. A classifier's probabilities are usually wrong as
6
+ confidences: this model is overconfident by 0.170 ECE raw. Temperature scaling
7
+ fits a single scalar T that rescales every distribution, softmax(log p / T),
8
+ so predicted confidence lines up with real accuracy. T is fit by minimising
9
+ negative log-likelihood on held-out data. It is monotonic, so it never
10
+ changes which answer wins, only how confident we are in it.
11
+
12
+ 2. Selective prediction. Given a target risk (max error rate you will tolerate
13
+ on answered items), pick the confidence threshold that holds that risk on a
14
+ calibration set. Below it, abstain, that is the honest "I don't know,
15
+ escalate" signal.
16
+
17
+ To report an honest "after" number we fit T on training folds and measure the
18
+ calibrated ECE on the held-out fold (k-fold CV), so we never grade calibration
19
+ on data it was fit on. Pure stdlib.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import random
26
+
27
+ from .metrics import DecisionRecord, ece as _ece
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Temperature scaling
32
+ # --------------------------------------------------------------------------- #
33
+
34
+ def apply_temperature(dist, T: float):
35
+ """Rescale a probability distribution by temperature T (softmax(log p / T)).
36
+
37
+ T = 1 is a no-op. T > 1 softens (less confident); T < 1 sharpens. Argmax is
38
+ preserved, so the picked class never changes.
39
+ """
40
+ if T <= 0:
41
+ raise ValueError("temperature must be > 0")
42
+ logits = [math.log(max(p, 1e-12)) / T for p in dist]
43
+ m = max(logits)
44
+ exps = [math.exp(l - m) for l in logits]
45
+ s = sum(exps)
46
+ return [e / s for e in exps]
47
+
48
+
49
+ def _nll(dists, gold_idx, T: float) -> float:
50
+ total = 0.0
51
+ for d, g in zip(dists, gold_idx):
52
+ q = apply_temperature(d, T)
53
+ total += -math.log(max(q[g], 1e-12))
54
+ return total / len(dists)
55
+
56
+
57
+ def fit_temperature(dists, gold_idx, coarse=None) -> float:
58
+ """Fit T by minimising NLL, coarse grid then a local refine. Robust and
59
+ transparent, no optimiser dependency."""
60
+ if not dists:
61
+ return 1.0
62
+ coarse = coarse or [0.25, 0.4, 0.55, 0.7, 0.85, 1.0, 1.25, 1.5,
63
+ 2.0, 2.5, 3.0, 4.0, 5.0, 6.0]
64
+ best = min(coarse, key=lambda T: _nll(dists, gold_idx, T))
65
+ # refine in a window around the coarse winner
66
+ step = 0.05
67
+ fine = [round(best + k * step, 4) for k in range(-6, 7) if best + k * step > 0.05]
68
+ best = min(fine + [best], key=lambda T: _nll(dists, gold_idx, T))
69
+ return best
70
+
71
+
72
+ def _records_with_dist(records: list[DecisionRecord]) -> list[DecisionRecord]:
73
+ return [r for r in records if r.dist and r.gold_index >= 0]
74
+
75
+
76
+ def _recalibrate_record(r: DecisionRecord, T: float) -> DecisionRecord:
77
+ q = apply_temperature(r.dist, T)
78
+ return DecisionRecord(
79
+ confidence=max(q),
80
+ correct=r.correct, # monotonic: winner unchanged
81
+ prob_gold=q[r.gold_index],
82
+ n_classes=r.n_classes,
83
+ task=r.task, question=r.question,
84
+ dist=tuple(q), gold_index=r.gold_index,
85
+ )
86
+
87
+
88
+ def cross_val_calibrate(records: list[DecisionRecord], k: int = 5, seed: int = 0):
89
+ """Fit T on train folds, apply to the held-out fold, aggregate.
90
+
91
+ Returns (calibrated_records, mean_T). The calibrated records are honest
92
+ held-out predictions: no record was calibrated by a T fit on itself.
93
+ """
94
+ usable = _records_with_dist(records)
95
+ if len(usable) < k:
96
+ T = fit_temperature([r.dist for r in usable], [r.gold_index for r in usable])
97
+ return [_recalibrate_record(r, T) for r in usable], T
98
+
99
+ rng = random.Random(seed)
100
+ idx = list(range(len(usable)))
101
+ rng.shuffle(idx)
102
+ folds = [idx[i::k] for i in range(k)]
103
+
104
+ calibrated: list[DecisionRecord] = []
105
+ temps: list[float] = []
106
+ for f in range(k):
107
+ test_ids = set(folds[f])
108
+ train = [usable[i] for i in idx if i not in test_ids]
109
+ test = [usable[i] for i in folds[f]]
110
+ T = fit_temperature([r.dist for r in train], [r.gold_index for r in train])
111
+ temps.append(T)
112
+ calibrated.extend(_recalibrate_record(r, T) for r in test)
113
+ return calibrated, sum(temps) / len(temps)
114
+
115
+
116
+ def fit_global_temperature(records: list[DecisionRecord]) -> float:
117
+ """Fit one T on all data, for saving into a deployable calibrator."""
118
+ usable = _records_with_dist(records)
119
+ return fit_temperature([r.dist for r in usable], [r.gold_index for r in usable])
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # Selective prediction (conformal-style thresholding)
124
+ # --------------------------------------------------------------------------- #
125
+
126
+ def fit_abstention_threshold(records: list[DecisionRecord], target_risk: float = 0.1):
127
+ """Pick the lowest confidence threshold that keeps error rate on accepted
128
+ decisions at or below ``target_risk`` on this set.
129
+
130
+ Returns (threshold, coverage, realized_risk). Accept a decision at inference
131
+ when its confidence >= threshold; otherwise abstain and escalate.
132
+ """
133
+ if not records:
134
+ return 1.0, 0.0, 0.0
135
+ ordered = sorted(records, key=lambda r: r.confidence, reverse=True)
136
+ n = len(ordered)
137
+ best = None # (threshold, coverage, risk) with the largest coverage under risk
138
+ errors = 0
139
+ for i, r in enumerate(ordered, start=1):
140
+ if not r.correct:
141
+ errors += 1
142
+ risk = errors / i
143
+ if risk <= target_risk:
144
+ best = (r.confidence, i / n, risk)
145
+ if best is None:
146
+ # even the single most-confident decision is wrong; abstain on everything
147
+ return 1.01, 0.0, 0.0
148
+ return best
149
+
150
+
151
+ def summarize_calibration(raw: list[DecisionRecord], k: int = 5, target_risk: float = 0.1):
152
+ """The M4 headline: ECE before vs after (held-out), plus a selective point."""
153
+ calibrated, mean_T = cross_val_calibrate(raw, k=k)
154
+ thr, cov, risk = fit_abstention_threshold(calibrated, target_risk)
155
+ return {
156
+ "temperature": mean_T,
157
+ "ece_before": _ece(_records_with_dist(raw)),
158
+ "ece_after": _ece(calibrated),
159
+ "target_risk": target_risk,
160
+ "abstain_threshold": thr,
161
+ "coverage_at_target": cov,
162
+ "risk_at_target": risk,
163
+ "calibrated_records": calibrated,
164
+ }
poorjev/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ """poorjev command line.
2
+
3
+ poorjev eval --set evalset/tasks.jsonl [--plots] [--verbose]
4
+ poorjev ask --state-file ticket.txt --questions questions.yaml (M4+)
5
+ poorjev calibrate --set evalset/tasks.jsonl (M4)
6
+
7
+ M3 ships ``eval``: run the set through the local backend and print accuracy +
8
+ ECE + Brier + AURC, overall and per task. ``--plots`` writes the reliability
9
+ diagram (the launch asset) once matplotlib is installed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+
17
+
18
+ def _fmt(summary: dict) -> str:
19
+ return (
20
+ f"n={summary['n']:>4} "
21
+ f"acc={summary['accuracy']:.3f} "
22
+ f"ECE={summary['ece']:.3f} "
23
+ f"Brier={summary['brier']:.3f} "
24
+ f"AURC={summary['aurc']:.3f}"
25
+ )
26
+
27
+
28
+ def cmd_eval(args) -> int:
29
+ from .evaluate import evaluate, report
30
+ from .client import Client
31
+
32
+ print(f"Running eval set: {args.set}")
33
+ print("(first run downloads a ~400MB model; then offline)\n")
34
+ client = Client()
35
+ records, _ = evaluate(args.set, client=client, verbose=args.verbose)
36
+ rep = report(records)
37
+
38
+ print("\n== overall ==")
39
+ print(" " + _fmt(rep["overall"]))
40
+ print("\n== by task ==")
41
+ for task, s in rep["by_task"].items():
42
+ print(f" {task:<16} " + _fmt(s))
43
+
44
+ if args.plots:
45
+ try:
46
+ from .plots import reliability_diagram
47
+ out = reliability_diagram(records, title="poorjev, raw (uncalibrated)",
48
+ path=args.plot_path)
49
+ print(f"\nwrote reliability diagram -> {out}")
50
+ except ImportError:
51
+ print("\n[--plots needs the 'plots' extra: pip install 'poorjev[plots]']")
52
+ return 0
53
+
54
+
55
+ def cmd_calibrate(args) -> int:
56
+ import json
57
+ from .evaluate import evaluate
58
+ from .client import Client
59
+ from .calibration import summarize_calibration, fit_global_temperature
60
+
61
+ print(f"Calibrating on: {args.set}")
62
+ print("(runs the eval set through the local model once)\n")
63
+ raw, _ = evaluate(args.set, client=Client())
64
+
65
+ summ = summarize_calibration(raw, k=args.folds, target_risk=args.target_risk)
66
+ global_T = fit_global_temperature(raw) # the deployable scalar
67
+
68
+ print("== calibration (5-fold held-out) ==")
69
+ print(f" temperature (mean) : {summ['temperature']:.3f}")
70
+ print(f" ECE before -> after : {summ['ece_before']:.3f} -> {summ['ece_after']:.3f}")
71
+ print(f" selective @ risk<={summ['target_risk']:.2f}: "
72
+ f"coverage={summ['coverage_at_target']:.2f} "
73
+ f"(threshold {summ['abstain_threshold']:.3f})")
74
+
75
+ saved = {
76
+ "temperature": global_T,
77
+ "target_risk": summ["target_risk"],
78
+ "abstain_threshold": summ["abstain_threshold"],
79
+ "ece_before": summ["ece_before"],
80
+ "ece_after": summ["ece_after"],
81
+ }
82
+ with open(args.out, "w") as f:
83
+ json.dump(saved, f, indent=2)
84
+ print(f"\nsaved calibrator -> {args.out}")
85
+
86
+ if args.plots:
87
+ try:
88
+ from .plots import reliability_pair, risk_coverage_plot
89
+ from .calibration import cross_val_calibrate
90
+ cal, _ = cross_val_calibrate(raw, k=args.folds)
91
+ p1 = reliability_pair(raw, cal, path=args.plot_path)
92
+ p2 = risk_coverage_plot(cal)
93
+ print(f"wrote {p1}")
94
+ print(f"wrote {p2}")
95
+ except ImportError:
96
+ print("[--plots needs the 'plots' extra: pip install 'poorjev[plots]']")
97
+ return 0
98
+
99
+
100
+ def cmd_ask(args) -> int:
101
+ import json
102
+ from .client import Client
103
+
104
+ temperature = 1.0
105
+ if args.calibrator:
106
+ try:
107
+ with open(args.calibrator) as f:
108
+ temperature = json.load(f).get("temperature", 1.0)
109
+ except FileNotFoundError:
110
+ print(f"[no calibrator at {args.calibrator}; using raw confidence]",
111
+ file=sys.stderr)
112
+
113
+ if args.state_file:
114
+ with open(args.state_file) as f:
115
+ state = f.read().strip()
116
+ else:
117
+ state = args.state
118
+ if not state:
119
+ print("provide --state or --state-file", file=sys.stderr)
120
+ return 1
121
+
122
+ with open(args.questions) as f:
123
+ specs = json.load(f) # {name: {"type":..., ...}}
124
+
125
+ from .evaluate import _build_primitive
126
+ questions = {name: _build_primitive(spec) for name, spec in specs.items()}
127
+ res = Client(temperature=temperature).ask(state, questions)
128
+
129
+ print(json.dumps({
130
+ name: {
131
+ "value": ans.value,
132
+ "confidence": round(ans.confidence, 4),
133
+ } for name, ans in res.items()
134
+ }, indent=2))
135
+ return 0
136
+
137
+
138
+ def cmd_serve(args) -> int:
139
+ from .mcp_server import main as serve_main
140
+ print("Starting poorjev MCP server (stdio). Add it to your MCP client config.",
141
+ file=sys.stderr)
142
+ serve_main(calibrator_path=args.calibrator)
143
+ return 0
144
+
145
+
146
+ def main(argv=None) -> int:
147
+ p = argparse.ArgumentParser(prog="poorjev", description="the poor man's Jev")
148
+ sub = p.add_subparsers(dest="command", required=True)
149
+
150
+ pe = sub.add_parser("eval", help="score the eval set (accuracy + calibration)")
151
+ pe.add_argument("--set", default="evalset/tasks.jsonl", help="path to tasks.jsonl")
152
+ pe.add_argument("--plots", action="store_true", help="write the reliability diagram")
153
+ pe.add_argument("--plot-path", default="reliability.png")
154
+ pe.add_argument("--verbose", action="store_true", help="print each item's answers")
155
+ pe.set_defaults(func=cmd_eval)
156
+
157
+ pa = sub.add_parser("ask", help="answer typed questions about one state")
158
+ pa.add_argument("--state", default="", help="the state text")
159
+ pa.add_argument("--state-file", help="read state from a file")
160
+ pa.add_argument("--questions", required=True, help="JSON file: {name: spec}")
161
+ pa.add_argument("--calibrator", default="calibration.json",
162
+ help="JSON from `poorjev calibrate` (applies the fitted temperature)")
163
+ pa.set_defaults(func=cmd_ask)
164
+
165
+ pc = sub.add_parser("calibrate", help="fit calibration on the eval set")
166
+ pc.add_argument("--set", default="evalset/tasks.jsonl")
167
+ pc.add_argument("--folds", type=int, default=5)
168
+ pc.add_argument("--target-risk", type=float, default=0.1)
169
+ pc.add_argument("--plots", action="store_true", help="write before/after diagrams")
170
+ pc.add_argument("--plot-path", default="docs/reliability_before_after.png")
171
+ pc.add_argument("--out", default="calibration.json")
172
+ pc.set_defaults(func=cmd_calibrate)
173
+
174
+ ps = sub.add_parser("serve", help="run the MCP server (for Claude Code etc.)")
175
+ ps.add_argument("--calibrator", default="calibration.json",
176
+ help="calibration.json to apply (fitted temperature)")
177
+ ps.set_defaults(func=cmd_serve)
178
+
179
+ args = p.parse_args(argv)
180
+ return args.func(args)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ raise SystemExit(main())
poorjev/client.py ADDED
@@ -0,0 +1,96 @@
1
+ """The Client: ask a batch of typed questions about one state, in a single pass.
2
+
3
+ The Client owns orchestration so backends stay simple. It:
4
+ 1. turns each question into (state, hypothesis) pairs,
5
+ 2. concatenates every pair across every question into ONE batched call,
6
+ 3. hands the raw entailment probabilities back to each primitive to decide.
7
+
8
+ A backend only has to implement ``entail_probs(pairs) -> list[float]``. The
9
+ default backend is the local NLI model (keyless, offline). Pass your own backend
10
+ (e.g. a fake in tests, or the future [llm] backend) to swap it out.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Protocol
16
+
17
+ from .primitives import Choice, Score, Noul, ChoiceAnswer, ScoreAnswer, NoulAnswer
18
+
19
+ DEFAULT_TEMPLATE = "This example is {}."
20
+
21
+
22
+ class Backend(Protocol):
23
+ def entail_probs(self, pairs: list[tuple[str, str]]) -> list[float]: ...
24
+
25
+
26
+ class Client:
27
+ def __init__(self, backend: Backend | None = None,
28
+ hypothesis_template: str = DEFAULT_TEMPLATE,
29
+ temperature: float = 1.0):
30
+ self._backend = backend
31
+ self.hypothesis_template = hypothesis_template
32
+ # A fitted temperature (from `poorjev calibrate`) makes ask()'s
33
+ # confidences calibrated. 1.0 is a no-op (raw).
34
+ self.temperature = temperature
35
+
36
+ @property
37
+ def backend(self) -> Backend:
38
+ if self._backend is None:
39
+ from .backends.local_nli import LocalNLIBackend
40
+ self._backend = LocalNLIBackend()
41
+ return self._backend
42
+
43
+ def ask(self, state: str, questions: dict):
44
+ """Answer every question about ``state`` in one batched pass.
45
+
46
+ Returns a dict mapping each question name to its typed answer
47
+ (ChoiceAnswer / ScoreAnswer / NoulAnswer).
48
+ """
49
+ if not isinstance(state, str) or state == "":
50
+ raise ValueError("state must be a non-empty string")
51
+ if not questions:
52
+ raise ValueError("ask() needs at least one question")
53
+
54
+ pairs: list[tuple[str, str]] = []
55
+ plan: list[tuple[str, object, int, int, str]] = [] # name, prim, start, count, mode
56
+
57
+ for name, prim in questions.items():
58
+ if isinstance(prim, Noul):
59
+ hyps = [prim.statement]
60
+ mode = "noul"
61
+ elif isinstance(prim, (Choice, Score)):
62
+ hyps = [self.hypothesis_template.format(h) for h in prim.hypotheses]
63
+ mode = "dist"
64
+ else:
65
+ raise TypeError(
66
+ f"question {name!r} must be a Choice, Score or Noul, got {type(prim).__name__}"
67
+ )
68
+ start = len(pairs)
69
+ pairs.extend((state, h) for h in hyps)
70
+ plan.append((name, prim, start, len(hyps), mode))
71
+
72
+ probs = self.backend.entail_probs(pairs) # the single pass
73
+ if len(probs) != len(pairs):
74
+ raise RuntimeError(
75
+ f"backend returned {len(probs)} scores for {len(pairs)} pairs"
76
+ )
77
+
78
+ out: dict[str, object] = {}
79
+ for name, prim, start, count, mode in plan:
80
+ seg = probs[start:start + count]
81
+ if mode == "noul":
82
+ p_true = seg[0]
83
+ if self.temperature != 1.0:
84
+ from .calibration import apply_temperature
85
+ p_true = apply_temperature([1.0 - p_true, p_true], self.temperature)[1]
86
+ out[name] = prim.decide(p_true, kind="prob")
87
+ else:
88
+ # entailment probs per option/level, normalised across the set
89
+ dist = seg
90
+ if self.temperature != 1.0:
91
+ from .calibration import apply_temperature
92
+ total = sum(max(0.0, s) for s in seg) or 1.0
93
+ norm = [max(0.0, s) / total for s in seg]
94
+ dist = apply_temperature(norm, self.temperature)
95
+ out[name] = prim.decide(dist, kind="probs")
96
+ return out
poorjev/evaluate.py ADDED
@@ -0,0 +1,95 @@
1
+ """Run the eval set through a backend and score it into DecisionRecords.
2
+
3
+ This is the bridge between the labelled data and the metrics. It builds the
4
+ right primitive for each question, asks the Client, then compares the picked
5
+ value to gold and records the confidence and the probability mass on the gold
6
+ answer. M4 will reuse the collected records (and their gold prob mass) to fit
7
+ and evaluate calibration.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from .primitives import Choice, Score, Noul
15
+ from .client import Client
16
+ from .metrics import DecisionRecord, summarize
17
+
18
+
19
+ def load_tasks(path: str) -> list[dict]:
20
+ items = []
21
+ with open(path) as f:
22
+ for line in f:
23
+ line = line.strip()
24
+ if line:
25
+ items.append(json.loads(line))
26
+ return items
27
+
28
+
29
+ def _build_primitive(spec: dict):
30
+ t = spec["type"]
31
+ if t == "choice":
32
+ return Choice(spec["options"])
33
+ if t == "score":
34
+ return Score(spec["levels"])
35
+ if t == "noul":
36
+ return Noul(spec["statement"])
37
+ raise ValueError(f"unknown question type {t!r}")
38
+
39
+
40
+ def _score_one(spec: dict, answer) -> DecisionRecord:
41
+ t = spec["type"]
42
+ gold = spec["gold"]
43
+ if t == "noul":
44
+ correct = bool(answer.value) == bool(gold)
45
+ prob_gold = answer.prob if gold else (1.0 - answer.prob)
46
+ # class order: index 0 = False, index 1 = True
47
+ dist = (1.0 - answer.prob, answer.prob)
48
+ gold_index = 1 if gold else 0
49
+ return DecisionRecord(answer.confidence, correct, prob_gold, n_classes=2,
50
+ dist=dist, gold_index=gold_index)
51
+ # choice / score share the same shape
52
+ order = spec["options"] if t == "choice" else spec["levels"]
53
+ probs = answer.probs if t == "choice" else answer.distribution
54
+ correct = answer.value == gold
55
+ prob_gold = probs.get(gold, 0.0)
56
+ dist = tuple(probs[k] for k in order)
57
+ gold_index = order.index(gold)
58
+ return DecisionRecord(answer.confidence, correct, prob_gold, n_classes=len(probs),
59
+ dist=dist, gold_index=gold_index)
60
+
61
+
62
+ def evaluate(path: str, client: Client | None = None, verbose: bool = False):
63
+ """Return (records, per_question_specs) after running the whole set.
64
+
65
+ ``records`` is a flat list of DecisionRecord across every question of every
66
+ item, tagged with task + question so callers can slice per task.
67
+ """
68
+ client = client or Client()
69
+ items = load_tasks(path)
70
+ records: list[DecisionRecord] = []
71
+
72
+ for item in items:
73
+ questions = {name: _build_primitive(spec) for name, spec in item["questions"].items()}
74
+ answers = client.ask(item["state"], questions)
75
+ for name, spec in item["questions"].items():
76
+ r = _score_one(spec, answers[name])
77
+ records.append(DecisionRecord(
78
+ r.confidence, r.correct, r.prob_gold, r.n_classes,
79
+ task=item["task"], question=name,
80
+ dist=r.dist, gold_index=r.gold_index,
81
+ ))
82
+ if verbose:
83
+ print(f" {item['id']}: " + ", ".join(
84
+ f"{n}={answers[n].value!r}" for n in item["questions"]
85
+ ))
86
+ return records, items
87
+
88
+
89
+ def report(records: list[DecisionRecord]) -> dict:
90
+ """Overall summary plus a per-task breakdown."""
91
+ out = {"overall": summarize(records), "by_task": {}}
92
+ tasks = sorted({r.task for r in records})
93
+ for task in tasks:
94
+ out["by_task"][task] = summarize([r for r in records if r.task == task])
95
+ return out