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 +25 -0
- ruling/against.py +50 -0
- ruling/build.py +257 -0
- ruling/calibration.py +182 -0
- ruling/cascade.py +83 -0
- ruling/cli.py +397 -0
- ruling/codes.py +30 -0
- ruling/config.py +120 -0
- ruling/corpus.py +202 -0
- ruling/decision.py +246 -0
- ruling/decision_train.py +208 -0
- ruling/distill.py +131 -0
- ruling/engine.py +518 -0
- ruling/evals.py +459 -0
- ruling/failover.py +62 -0
- ruling/history.html +78 -0
- ruling/hosted.py +394 -0
- ruling/ledger.py +120 -0
- ruling/metrics.py +66 -0
- ruling/modernbert.py +163 -0
- ruling/peft.py +144 -0
- ruling/playground.html +217 -0
- ruling/prompt.py +134 -0
- ruling/questions.py +280 -0
- ruling/registry.py +195 -0
- ruling/retrain.py +125 -0
- ruling/sdk.py +52 -0
- ruling/server.py +198 -0
- ruling/states.py +200 -0
- ruling/thinking.py +75 -0
- ruling/train.py +481 -0
- ruling/vision.py +100 -0
- ruling/worlds.py +134 -0
- ruling-0.2.0.dist-info/METADATA +83 -0
- ruling-0.2.0.dist-info/RECORD +38 -0
- ruling-0.2.0.dist-info/WHEEL +4 -0
- ruling-0.2.0.dist-info/entry_points.txt +2 -0
- ruling-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
|