ruling 0.2.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.
ruling/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """ruling: typed, probabilistic decisions from a local open-weight model."""
2
+
3
+ from ruling.questions import (
4
+ Choice,
5
+ ChoiceAnswer,
6
+ Noul,
7
+ NoulAnswer,
8
+ Score,
9
+ ScoreAnswer,
10
+ SystemOneRequest,
11
+ SystemOneResponse,
12
+ )
13
+ from ruling.sdk import RulingClient
14
+
15
+ __all__ = [
16
+ "Choice",
17
+ "ChoiceAnswer",
18
+ "Noul",
19
+ "NoulAnswer",
20
+ "RulingClient",
21
+ "Score",
22
+ "ScoreAnswer",
23
+ "SystemOneRequest",
24
+ "SystemOneResponse",
25
+ ]
ruling/against.py ADDED
@@ -0,0 +1,50 @@
1
+ """Send a dataset to the hosted TypeSafe API so Jev's live answers can sit next to ruling's.
2
+
3
+ TypeSafe's HTTP request and response shapes are the same as ruling's, so this
4
+ is a thin client: bearer token, one POST per record, answers converted to
5
+ distributions over the record's canonical keys. Nothing but the record's state
6
+ and questions is sent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import httpx
14
+
15
+ from ruling.evals import Distribution, Record, answer_distribution
16
+ from ruling.questions import option_keys
17
+
18
+ API_KEY_ENV = "TYPESAFE_API_KEY"
19
+ BASE_URL_ENV = "TYPESAFE_BASE_URL"
20
+ DEFAULT_BASE_URL = "https://api.typesafe.ai"
21
+
22
+
23
+ def live_answers(
24
+ records: list[Record],
25
+ base_url: str | None = None,
26
+ api_key: str | None = None,
27
+ model: str | None = None,
28
+ timeout: float = 30.0,
29
+ ):
30
+ """Yield (record index, answer distributions by question id) from the hosted API, one request per record."""
31
+ base_url = base_url or os.environ.get(BASE_URL_ENV, DEFAULT_BASE_URL)
32
+ api_key = api_key or os.environ.get(API_KEY_ENV)
33
+ headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
34
+ with httpx.Client(base_url=base_url, headers=headers, timeout=timeout) as client:
35
+ for index, record in enumerate(records):
36
+ body = {"state": record.state,
37
+ "questions": {qid: q.model_dump(exclude_none=True) for qid, q in record.questions.items()}}
38
+ if model is not None:
39
+ body["model"] = model
40
+ response = client.post("/v1/systemone", json=body)
41
+ response.raise_for_status()
42
+ yield index, {qid: answer_distribution(answer, option_keys(record.questions[qid]))
43
+ for qid, answer in response.json()["answers"].items()}
44
+
45
+
46
+ def collect_live(records: list[Record], base_url: str | None = None, api_key: str | None = None,
47
+ model: str | None = None, timeout: float = 30.0) -> dict[tuple[int, str], Distribution]:
48
+ return {(index, qid): distribution
49
+ for index, answers in live_answers(records, base_url, api_key, model, timeout)
50
+ for qid, distribution in answers.items()}
ruling/build.py ADDED
@@ -0,0 +1,257 @@
1
+ """Build labeled datasets from pinned public sources.
2
+
3
+ Nothing here is committed; the builders download a pinned revision and write
4
+ records in the ruling dataset format. WANLI rows follow the 256-row selection
5
+ published by TheoLeeCJ/openjev so results stay comparable. SST-5 gives an
6
+ ordinal five-level Score benchmark. The TypeSafe and Every builders carry
7
+ Jev's own published answers as references, so agreement with Jev can be
8
+ reported on identical inputs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import io
15
+ import json
16
+ import random
17
+ import re
18
+ import zipfile
19
+ from pathlib import Path
20
+
21
+ import httpx
22
+ from huggingface_hub import hf_hub_download
23
+
24
+ from ruling.evals import Distribution, answer_distribution
25
+
26
+ DATASETS = Path(__file__).resolve().parents[1] / "datasets"
27
+
28
+ WANLI = {"repo": "alisawuffles/WANLI", "revision": "61c95318fd71c55b6ba355d76253254615f387ec", "file": "test.jsonl"}
29
+ SST5 = {"repo": "SetFit/sst5", "revision": "e51bdcd8cd3a30da231967c1a249ba59361279a3", "file": "test.jsonl"}
30
+ WANLI_SELECTION = DATASETS / "wanli256-selection.jsonl"
31
+ TYPESAFE_SELECTION = DATASETS / "typesafe102-selection.jsonl"
32
+ TYPESAFE_CASES = "https://evals.typesafe.ai/{workflow}-cases.js"
33
+ EVERY_EXPERIMENTS = "https://typesafe-parallel-judgment-lab.every-4573.chatgpt.site/downloads/experiments.json"
34
+ EVERY_ARCHIVE = "https://typesafe-parallel-judgment-lab.every-4573.chatgpt.site/downloads/typesafe-lab-source.zip"
35
+ EVERY_EXPERIMENTS_GENERATED_AT = "2026-08-28T19:55:23.191Z"
36
+ EVERY_ARCHIVE_SHA256 = "9fbf42e9d9e7cd3b072e0271a0959dbfdca85618e93fe4f0ca519d683c418bf0"
37
+
38
+ NLI_DESCRIPTIONS = {
39
+ "supported": "The evidence establishes the claim",
40
+ "insufficient": "The evidence does not establish either",
41
+ "contradicted": "The evidence establishes the opposite",
42
+ }
43
+ NLI_LABELS = {"entailment": "supported", "neutral": "insufficient", "contradiction": "contradicted"}
44
+ SST5_LEVELS = ["very negative", "negative", "neutral", "positive", "very positive"]
45
+
46
+
47
+ def _rows(source: dict) -> list[dict]:
48
+ path = hf_hub_download(source["repo"], source["file"], repo_type="dataset", revision=source["revision"])
49
+ return [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
50
+
51
+
52
+ def _fetch(url: str) -> bytes:
53
+ response = httpx.get(url, timeout=60.0, follow_redirects=True)
54
+ response.raise_for_status()
55
+ return response.content
56
+
57
+
58
+ def build_wanli() -> list[dict]:
59
+ """256 NLI rows as a 3-way Choice plus a Noul on entailment."""
60
+ by_id = {str(row["id"]): row for row in _rows(WANLI)}
61
+ selection = [json.loads(line) for line in WANLI_SELECTION.read_text().splitlines() if line.strip()]
62
+ records = []
63
+ for item in selection:
64
+ row = by_id[str(item["upstream"]["source_id"])]
65
+ gold = NLI_LABELS[row["gold"]]
66
+ records.append({
67
+ "family": item["family"],
68
+ "source_id": row["id"],
69
+ "state": row["premise"],
70
+ "questions": {
71
+ "verdict": {"type": "choice",
72
+ "instructions": "Assess the claim using only the supplied evidence: " + row["hypothesis"],
73
+ "criteria": {key: NLI_DESCRIPTIONS[key] for key in item["option_ids"]}},
74
+ "supported": {"type": "noul",
75
+ "instructions": "The evidence establishes this claim: " + row["hypothesis"]},
76
+ },
77
+ "labels": {"verdict": gold, "supported": gold == "supported"},
78
+ })
79
+ if len(records) != 256:
80
+ raise ValueError(f"expected 256 WANLI rows, built {len(records)}")
81
+ return records
82
+
83
+
84
+ def build_sst5(limit: int, seed: int = 0) -> list[dict]:
85
+ """A seeded sample of SST-5 test sentences as a five-level Score."""
86
+ rows = _rows(SST5)
87
+ sample = random.Random(seed).sample(rows, limit)
88
+ return [{
89
+ "family": "sentiment",
90
+ "state": row["text"],
91
+ "questions": {"sentiment": {"type": "score", "instructions": "What is the sentiment of the review sentence?",
92
+ "criteria": SST5_LEVELS}},
93
+ "labels": {"sentiment": int(row["label"])},
94
+ } for row in sample]
95
+
96
+
97
+ def build_typesafe() -> list[dict]:
98
+ """The 102 TypeSafe public-eval rows TheoLeeCJ aligned, with Jev's published answers.
99
+
100
+ Labels are the argmax of TypeSafe's reference, the mean of GPT-6 Astra and
101
+ Claude Fable 5.1 at high reasoning, so they measure agreement with big
102
+ models, not ground truth. The selection manifest pins a hash of each
103
+ workflow payload and the build refuses to run if a payload has changed.
104
+ """
105
+ selection = [json.loads(line) for line in TYPESAFE_SELECTION.read_text().splitlines() if line.strip()]
106
+ payloads: dict[str, dict] = {}
107
+ for workflow in sorted({item["upstream"]["workflow"] for item in selection}):
108
+ text = _fetch(TYPESAFE_CASES.format(workflow=workflow)).decode()
109
+ prefix = "__VIEWER_DATA__("
110
+ if not text.startswith(prefix):
111
+ raise ValueError(f"unexpected wrapper in TypeSafe {workflow} payload")
112
+ payload, _ = json.JSONDecoder().raw_decode(text[len(prefix):])
113
+ digest = hashlib.sha256(json.dumps(payload, indent=2).encode()).hexdigest()
114
+ pinned = {item["upstream"]["snapshot_sha256"] for item in selection if item["upstream"]["workflow"] == workflow}
115
+ if digest not in pinned:
116
+ raise ValueError(f"TypeSafe {workflow} payload changed since the selection was frozen")
117
+ payloads[workflow] = payload["eval"]
118
+
119
+ records = []
120
+ for item in selection:
121
+ up = item["upstream"]
122
+ evaluation = payloads[up["workflow"]]
123
+ case = evaluation["cases"][up["case_id"]]
124
+ source_question = evaluation["questions"][up["question_index"]]
125
+ if source_question["type"] == "noul":
126
+ criteria = source_question.get("criteria") or {}
127
+ question = {"type": "noul", "instructions": source_question["instructions"],
128
+ "criteria": {"true": criteria.get("true"), "false": criteria.get("false")}}
129
+ keys, source_keys = ["yes", "no"], ["true", "false"]
130
+ elif source_question["type"] == "choice":
131
+ question = {"type": "choice", "instructions": source_question["instructions"],
132
+ "criteria": source_question["criteria"]}
133
+ keys = source_keys = list(question["criteria"])
134
+ else:
135
+ raise ValueError("the frozen selection excludes score questions")
136
+ if source_keys != item["option_ids"]:
137
+ raise ValueError(f"option order changed for {item['id']}")
138
+
139
+ sets = case["reference_answers"][up["node"]][up["question_id"]]["sets"]
140
+ reference = dict(zip(keys, _mean_reference(sets, source_keys)))
141
+ top = max(reference, key=reference.get)
142
+ if sum(1 for v in reference.values() if abs(v - reference[top]) < 1e-12) != 1:
143
+ raise ValueError(f"reference argmax is not unique for {item['id']}")
144
+ published = next(
145
+ (node["answers"][up["question_id"]] for node in case["models"]["typesafe"]["nodes"]
146
+ if node["node"] == up["node"] and node.get("doc") == up["document_index"]
147
+ and node.get("questions", {}).get(up["question_id"]) == up["question_index"]
148
+ and up["question_id"] in node.get("answers", {})),
149
+ None,
150
+ )
151
+ if published is None:
152
+ raise ValueError(f"no published Jev answer for {item['id']}")
153
+ records.append({
154
+ "family": item["family"],
155
+ "source_id": item["id"],
156
+ "state": evaluation["documents"][up["document_index"]],
157
+ "questions": {up["question_id"]: question},
158
+ "labels": {up["question_id"]: (top == "yes") if question["type"] == "noul" else top},
159
+ "references": {up["question_id"]: {"jev": answer_distribution(published, keys), "reference": reference}},
160
+ })
161
+ if len(records) != 102:
162
+ raise ValueError(f"expected 102 TypeSafe rows, built {len(records)}")
163
+ return records
164
+
165
+
166
+ def _mean_reference(sets: list[dict], keys: list[str]) -> list[float]:
167
+ total = [0.0] * len(keys)
168
+ for answer in sets:
169
+ if answer.get("probabilities"):
170
+ raw = {str(k): float(v) for k, v in answer["probabilities"].items()}
171
+ norm = sum(raw.get(k, 0.0) for k in keys)
172
+ for i, key in enumerate(keys):
173
+ total[i] += raw.get(key, 0.0) / norm
174
+ else:
175
+ value = answer["value"]
176
+ key = str(value).lower() if isinstance(value, bool) else str(value)
177
+ total[keys.index(key)] += 1.0
178
+ return [value / len(sets) for value in total]
179
+
180
+
181
+ def build_every() -> list[dict]:
182
+ """Every's parallel judgment lab: Jev's recorded answers on identical inputs.
183
+
184
+ One record per document with all of that experiment's questions. Gold
185
+ labels exist for the judge grid and the two retrieval grids (author
186
+ labels); the other experiments carry only Jev's answers, so they measure
187
+ agreement. The AI-writing checker is skipped because only excerpts of its
188
+ documents are published.
189
+ """
190
+ data = json.loads(_fetch(EVERY_EXPERIMENTS))
191
+ if data["generated_at"] != EVERY_EXPERIMENTS_GENERATED_AT:
192
+ raise ValueError(f"Every experiments file changed: generated_at is {data['generated_at']}")
193
+ archive_bytes = _fetch(EVERY_ARCHIVE)
194
+ if hashlib.sha256(archive_bytes).hexdigest() != EVERY_ARCHIVE_SHA256:
195
+ raise ValueError("Every source archive changed")
196
+ archive = zipfile.ZipFile(io.BytesIO(archive_bytes))
197
+ script = archive.read("scripts/run-experiments.mjs").decode()
198
+ policy = json.loads(re.search(r'const SUPPORT_POLICY = ("[^\n]+");', script).group(1))
199
+ experiments = {e["id"]: e for e in data["experiments"]}
200
+ records: list[dict] = []
201
+
202
+ grid = experiments["judge-grid"]
203
+ cells = {(c["output"], c["question"]): c for c in grid["typesafe"]["cells"]}
204
+ for document in grid["documents"]:
205
+ questions = {qid: {"type": "noul", "instructions": text} for qid, text in grid["questions"].items()}
206
+ records.append({
207
+ "family": "every_judge-grid",
208
+ "state": {"policy": policy, "candidate_response": document["response"]},
209
+ "questions": questions,
210
+ "labels": {qid: bool(document["expected"][qid]) for qid in questions},
211
+ "references": {qid: {"jev": _noul(cells[document["id"], qid]["value"])} for qid in questions},
212
+ })
213
+
214
+ for name, folder in (("code-rag", "code-repo"), ("company-brain", "company-brain")):
215
+ by_document: dict[str, dict] = {}
216
+ for query in experiments[name]["grid"]:
217
+ for cell in query["cells"]:
218
+ entry = by_document.setdefault(cell["document"], {"questions": {}, "labels": {}, "references": {}})
219
+ entry["questions"][query["id"]] = {"type": "noul", "instructions": query["question"]}
220
+ entry["labels"][query["id"]] = bool(cell["relevant"])
221
+ entry["references"][query["id"]] = {"jev": _noul(cell["value"])}
222
+ for document, entry in by_document.items():
223
+ records.append({"family": f"every_{name}", "state": archive.read(f"fixtures/{folder}/{document}").decode(), **entry})
224
+
225
+ for name in ("action-firewall", "vc-diligence"):
226
+ experiment = experiments[name]
227
+ questions = {qid: {"type": "noul", "instructions": text} for qid, text in experiment["questions"].items()}
228
+ for row in experiment["rows"]:
229
+ records.append({"family": f"every_{name}", "state": row["text"], "questions": questions,
230
+ "references": {qid: {"jev": _noul(row["signals"][qid])} for qid in questions}})
231
+
232
+ for name in ("customer-voice", "ceo-radar", "inbox-urgency", "persona-panel"):
233
+ experiment = experiments[name]
234
+ questions = {qid: ({"type": "noul", "instructions": spec} if isinstance(spec, str) else spec)
235
+ for qid, spec in experiment["questions"].items()}
236
+ for row in experiment["rows"]:
237
+ references = {qid: {"jev": answer_distribution(answer, _keys(questions[qid]))}
238
+ for qid, answer in row["typed_answers"].items()}
239
+ records.append({"family": f"every_{name}", "state": row["document"], "questions": questions,
240
+ "references": references})
241
+ return records
242
+
243
+
244
+ def _noul(probability: float) -> Distribution:
245
+ return {"yes": float(probability), "no": 1.0 - float(probability)}
246
+
247
+
248
+ def _keys(question: dict) -> list[str]:
249
+ if question["type"] == "choice":
250
+ return list(question["criteria"])
251
+ if question["type"] == "score":
252
+ return [str(i) for i in range(len(question["criteria"]))]
253
+ return ["yes", "no"]
254
+
255
+
256
+ def write(records: list[dict], output: Path) -> None:
257
+ output.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records))
ruling/calibration.py ADDED
@@ -0,0 +1,182 @@
1
+ """Temperature scaling, confidence, and calibration metrics.
2
+
3
+ Restricted log-probabilities (one per allowed answer) are divided by a
4
+ per-question-type temperature before the softmax. A temperature above 1
5
+ flattens an overconfident model; below 1 sharpens an underconfident one.
6
+ Temperatures are fit on labeled data by minimizing negative log-likelihood and
7
+ stored with the model, revision, and scoring settings they were fit for, so a
8
+ file cannot silently apply to a different model.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ QUESTION_TYPES = ("choice", "score", "noul")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Provenance:
25
+ model: str
26
+ revision: str | None
27
+ rotations: int
28
+ prior_debias: bool
29
+ adapter: str | None = None
30
+
31
+ def mismatch(self, other: "Provenance") -> list[str]:
32
+ return [name for name in ("model", "revision", "rotations", "prior_debias", "adapter")
33
+ if getattr(self, name) != getattr(other, name)]
34
+
35
+
36
+ @dataclass
37
+ class Calibration:
38
+ temperatures: dict[str, float] = field(default_factory=dict)
39
+ provenance: Provenance | None = None
40
+
41
+ def temperature(self, question_type: str) -> float:
42
+ return self.temperatures.get(question_type, 1.0)
43
+
44
+ @classmethod
45
+ def load(cls, path: Path) -> "Calibration":
46
+ data = json.loads(path.read_text())
47
+ temperatures = {k: float(v) for k, v in data["temperatures"].items()}
48
+ for question_type, value in temperatures.items():
49
+ if question_type not in QUESTION_TYPES or value <= 0:
50
+ raise ValueError(f"invalid temperature for {question_type!r}: {value}")
51
+ return cls(temperatures=temperatures, provenance=Provenance(**data["provenance"]))
52
+
53
+ def save(self, path: Path) -> None:
54
+ if self.provenance is None:
55
+ raise ValueError("a calibration cannot be saved without provenance")
56
+ data = {"temperatures": self.temperatures, "provenance": self.provenance.__dict__}
57
+ path.write_text(json.dumps(data, indent=2) + "\n")
58
+
59
+ def check(self, provenance: Provenance) -> None:
60
+ """Raise unless this calibration was fit for exactly this model and scoring setup."""
61
+ if self.provenance is None:
62
+ return
63
+ fields = self.provenance.mismatch(provenance)
64
+ if fields:
65
+ details = ", ".join(f"{f}: calibrated for {getattr(self.provenance, f)!r}, running {getattr(provenance, f)!r}" for f in fields)
66
+ raise ValueError(f"calibration does not match the running engine ({details})")
67
+
68
+
69
+ def softmax(logits: np.ndarray, temperature: float = 1.0) -> np.ndarray:
70
+ scaled = np.asarray(logits, dtype=np.float64) / temperature
71
+ scaled -= scaled.max()
72
+ exp = np.exp(scaled)
73
+ return exp / exp.sum()
74
+
75
+
76
+ def log_softmax(logits: np.ndarray) -> np.ndarray:
77
+ shifted = np.asarray(logits, dtype=np.float64) - np.max(logits)
78
+ return shifted - np.log(np.exp(shifted).sum())
79
+
80
+
81
+ def choice_confidence(probabilities: np.ndarray) -> float:
82
+ """How far the leading option stands above a uniform distribution, from 0 to 1.
83
+
84
+ This and `score_confidence` reimplement the formulas TypeSafe publishes in
85
+ its MIT-licensed system-one-adapter, so a confidence value here means the
86
+ same thing as one from the hosted API.
87
+ """
88
+ probabilities = _normalized(probabilities)
89
+ if probabilities.size == 1:
90
+ return 1.0
91
+ uniform = 1.0 / probabilities.size
92
+ return float((probabilities.max() - uniform) / (1.0 - uniform))
93
+
94
+
95
+ def score_confidence(probabilities: np.ndarray) -> float:
96
+ """How tightly a score's mass sits around its modal level, from 0 to 1."""
97
+ probabilities = _normalized(probabilities)
98
+ if probabilities.size == 1:
99
+ return 1.0
100
+ levels = np.arange(probabilities.size)
101
+ mode = int(probabilities.argmax())
102
+ spread = float((probabilities * np.abs(levels - mode)).sum())
103
+ uniform_spread = float(np.abs(levels - (probabilities.size - 1) / 2).mean())
104
+ return max(0.0, 1.0 - spread / uniform_spread)
105
+
106
+
107
+ def _normalized(probabilities: np.ndarray) -> np.ndarray:
108
+ probabilities = np.asarray(probabilities, dtype=np.float64)
109
+ total = probabilities.sum()
110
+ return np.full(probabilities.size, 1.0 / probabilities.size) if total == 0 else probabilities / total
111
+
112
+
113
+ def negative_log_likelihood(logits: list[np.ndarray], labels: list[int], temperature: float) -> float:
114
+ total = 0.0
115
+ for row, label in zip(logits, labels):
116
+ total -= math.log(max(softmax(row, temperature)[label], 1e-12))
117
+ return total / len(labels)
118
+
119
+
120
+ def fit_temperature(logits: list[np.ndarray], labels: list[int]) -> float:
121
+ """Golden-section search over log-temperature for the NLL minimum."""
122
+ if not logits:
123
+ raise ValueError("cannot fit a temperature without examples")
124
+ lo, hi = math.log(0.05), math.log(20.0)
125
+ ratio = (math.sqrt(5) - 1) / 2
126
+ a = hi - ratio * (hi - lo)
127
+ b = lo + ratio * (hi - lo)
128
+ fa = negative_log_likelihood(logits, labels, math.exp(a))
129
+ fb = negative_log_likelihood(logits, labels, math.exp(b))
130
+ for _ in range(60):
131
+ if fa < fb:
132
+ hi, b, fb = b, a, fa
133
+ a = hi - ratio * (hi - lo)
134
+ fa = negative_log_likelihood(logits, labels, math.exp(a))
135
+ else:
136
+ lo, a, fa = a, b, fb
137
+ b = lo + ratio * (hi - lo)
138
+ fb = negative_log_likelihood(logits, labels, math.exp(b))
139
+ return math.exp((lo + hi) / 2)
140
+
141
+
142
+ def coverage_at_error(confidence: list[float], correct: list[bool], budget: float) -> float:
143
+ """The largest share of decisions acceptable in descending-confidence order while the
144
+ error rate among the accepted stays within `budget`.
145
+
146
+ This is jev-benchmarks' selective-automation metric. Equal confidences are admitted as
147
+ a whole group, so a permutation of tied rows cannot change the answer. A model that
148
+ is confidently wrong scores low here whatever its accuracy; a temperature cannot move
149
+ it, because the order is what is measured.
150
+ """
151
+ if not 0 <= budget <= 1:
152
+ raise ValueError("error budget must be within [0, 1]")
153
+ confidence, correct = np.asarray(confidence, dtype=np.float64), np.asarray(correct, dtype=bool)
154
+ if confidence.size == 0:
155
+ return 0.0
156
+ order = np.argsort(-confidence, kind="stable")
157
+ ranked, errors = confidence[order], np.cumsum(~correct[order])
158
+ group_ends = np.r_[np.flatnonzero(ranked[1:] != ranked[:-1]), ranked.size - 1]
159
+ accepted = group_ends + 1
160
+ within = np.flatnonzero(errors[group_ends] <= budget * accepted)
161
+ return float(accepted[within[-1]] / ranked.size) if within.size else 0.0
162
+
163
+
164
+ def confident_error_rate(confidence: list[float], correct: list[bool], threshold: float = 0.9) -> float:
165
+ """The share of all decisions that were wrong while reporting at least `threshold`."""
166
+ confidence, correct = np.asarray(confidence, dtype=np.float64), np.asarray(correct, dtype=bool)
167
+ return float(np.mean((confidence >= threshold) & ~correct)) if confidence.size else 0.0
168
+
169
+
170
+ def expected_calibration_error(top_probabilities: list[float], correct: list[bool], bins: int = 10) -> float:
171
+ """Gap between stated top-1 probability and observed accuracy, weighted by bin size."""
172
+ if not top_probabilities:
173
+ return 0.0
174
+ probs = np.asarray(top_probabilities, dtype=np.float64)
175
+ hits = np.asarray(correct, dtype=np.float64)
176
+ edges = np.linspace(0.0, 1.0, bins + 1)
177
+ error = 0.0
178
+ for lo, hi in zip(edges[:-1], edges[1:]):
179
+ mask = (probs > lo) & (probs <= hi) if lo > 0 else (probs >= lo) & (probs <= hi)
180
+ if mask.any():
181
+ error += mask.mean() * abs(probs[mask].mean() - hits[mask].mean())
182
+ return float(error)
ruling/cascade.py ADDED
@@ -0,0 +1,83 @@
1
+ """Two engines behind one answer: a small one for every question, a larger one for the doubtful ones.
2
+
3
+ The first engine scores the whole request. Any question whose calibrated top
4
+ probability falls under the threshold is scored again by the second engine, and
5
+ that answer replaces the first. Measured on TypeSafe's and Every's public rows
6
+ with a threshold fitted on one set and applied to the other, the second engine
7
+ ran on about 13% of questions and the pair matched its accuracy alone.
8
+
9
+ Each engine's own temperature is applied here, so the logits handed back are
10
+ already calibrated and the cascade carries an identity calibration of its own.
11
+
12
+ When the second engine is a host that is overloaded or unreachable, the first
13
+ engine's answers stand for the questions that would have been escalated: the
14
+ request degrades to the primary's own confidence instead of failing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+
21
+ import numpy as np
22
+
23
+ from ruling.calibration import Calibration, Provenance, softmax
24
+ from ruling.engine import RawScore, Scored, answer
25
+ from ruling.hosted import HostUnavailable
26
+ from ruling.questions import Choice, Noul, Score, State, SystemOneRequest, SystemOneResponse, Usage
27
+
28
+ PREFIX = "cascade:"
29
+ log = logging.getLogger("ruling")
30
+
31
+
32
+ class CascadeEngine:
33
+ """Same interface as the engines it wraps, so the server and the harness need no special case."""
34
+
35
+ prior_debias = False
36
+
37
+ def __init__(self, first, second, threshold: float):
38
+ if not 0 <= threshold <= 1:
39
+ raise ValueError("cascade threshold must be a probability")
40
+ self.first, self.second, self.threshold = first, second, threshold
41
+ self.model_id = f"{PREFIX}{first.model_id}>{second.model_id}"
42
+ self.revision = None
43
+ self.adapter = first.adapter
44
+ self.rotations = first.rotations
45
+ self.max_input_tokens = min(first.max_input_tokens, second.max_input_tokens)
46
+ self.max_branch_tokens = min(first.max_branch_tokens, second.max_branch_tokens)
47
+ self.max_options = min(first.max_options, second.max_options)
48
+ self.reads_images = first.reads_images and second.reads_images
49
+ self.calibration = Calibration()
50
+
51
+ @property
52
+ def provenance(self) -> Provenance:
53
+ return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
54
+
55
+ def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
56
+ first = self.first.score(state, questions)
57
+ raw = {qid: self._tempered(self.first, q, first.raw[qid]) for qid, q in questions.items()}
58
+ doubtful = {qid: q for qid, q in questions.items() if softmax(raw[qid].logits).max() < self.threshold}
59
+ tokens = first.input_tokens
60
+ if doubtful:
61
+ try:
62
+ second = self.second.score(state, doubtful)
63
+ except HostUnavailable as exc:
64
+ log.warning("cascade kept %s's answers for %d doubtful questions: %s", self.first.model_id, len(doubtful), exc)
65
+ return Scored(raw=raw, input_tokens=tokens)
66
+ tokens += second.input_tokens
67
+ for qid, q in doubtful.items():
68
+ raw[qid] = self._tempered(self.second, q, second.raw[qid])
69
+ return Scored(raw=raw, input_tokens=tokens)
70
+
71
+ @staticmethod
72
+ def _tempered(engine, question: Choice | Score | Noul, raw: RawScore) -> RawScore:
73
+ """The engine's logits with its own fitted temperature already divided out."""
74
+ temperature = engine.calibration.temperature(question.type)
75
+ return RawScore(keys=raw.keys, logits=np.asarray(raw.logits, dtype=np.float64) / temperature)
76
+
77
+ def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
78
+ scored = self.score(request.state, request.questions)
79
+ answers = {qid: answer(request.questions[qid], scored.raw[qid], self.calibration) for qid in request.questions}
80
+ return SystemOneResponse(model=self.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens))
81
+
82
+ def answer(self, question, raw: RawScore):
83
+ return answer(question, raw, self.calibration)