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/evals.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Labeled-dataset evaluation and temperature fitting.
|
|
2
|
+
|
|
3
|
+
A dataset is JSONL, or a JSON array. Each record holds a state, a questions
|
|
4
|
+
map in the API shape, and optionally: `labels`, gold answers by question id
|
|
5
|
+
(an option key for Choice, a level index or level text for Score, a boolean
|
|
6
|
+
for Noul); `references`, published answer distributions by question id and
|
|
7
|
+
source name, such as Jev's own answers, used to report agreement; `family`
|
|
8
|
+
for grouped balanced-accuracy reporting; and an optional `id` and `weight`.
|
|
9
|
+
|
|
10
|
+
Rows written for hosted decision platforms are the same shape and load unchanged. Their extra label forms
|
|
11
|
+
are normalized on validation: the strings "true"/"false" become a boolean, a
|
|
12
|
+
score option id such as "2" becomes the index 2, and a soft label (a
|
|
13
|
+
probability of yes, a per-option map, or a per-level list) becomes the
|
|
14
|
+
`target` reference with the label set to its most likely option. A
|
|
15
|
+
single-question row holds `question` and `label` (or `target`, or
|
|
16
|
+
`expected`) instead of maps, and becomes one question named `decision`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from pydantic import BaseModel, Field, model_validator
|
|
30
|
+
|
|
31
|
+
from ruling.calibration import (
|
|
32
|
+
Calibration,
|
|
33
|
+
Provenance,
|
|
34
|
+
choice_confidence,
|
|
35
|
+
confident_error_rate,
|
|
36
|
+
coverage_at_error,
|
|
37
|
+
expected_calibration_error,
|
|
38
|
+
fit_temperature,
|
|
39
|
+
softmax,
|
|
40
|
+
)
|
|
41
|
+
from ruling.engine import InputTooLong
|
|
42
|
+
from ruling.metrics import decision_metrics
|
|
43
|
+
from ruling.questions import (
|
|
44
|
+
MAX_CHOICE_OPTIONS,
|
|
45
|
+
Choice,
|
|
46
|
+
InvalidOptions,
|
|
47
|
+
Noul,
|
|
48
|
+
Question,
|
|
49
|
+
Score,
|
|
50
|
+
State,
|
|
51
|
+
SystemOneRequest,
|
|
52
|
+
UnsupportedModality,
|
|
53
|
+
check_askable,
|
|
54
|
+
check_images,
|
|
55
|
+
check_readable,
|
|
56
|
+
option_keys,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
Distribution = dict[str, float]
|
|
60
|
+
Label = str | int | bool | float | list[float] | dict[str, float]
|
|
61
|
+
|
|
62
|
+
TARGET = "target"
|
|
63
|
+
"""The reference name whose distribution is the soft training and scoring target for a question."""
|
|
64
|
+
|
|
65
|
+
SINGLE_QUESTION = "decision"
|
|
66
|
+
"""The question id a single-question row is given, the name hosted platforms use for it."""
|
|
67
|
+
|
|
68
|
+
SOFT_SUM_TOLERANCE = 0.02
|
|
69
|
+
"""How far a soft label may stray from summing to one before it is refused; the tolerance hosted platforms allow."""
|
|
70
|
+
|
|
71
|
+
NOUL_KEYS = {"true": "yes", "false": "no"}
|
|
72
|
+
"""The true/false noul option ids hosted platforms use, mapped onto ruling's canonical keys."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
ERROR_BUDGET = 0.05
|
|
76
|
+
"""The error rate a deployment accepts among automated decisions; coverage is reported against it."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Record(BaseModel):
|
|
80
|
+
id: str | int | None = None
|
|
81
|
+
state: State
|
|
82
|
+
questions: dict[str, Question] = Field(min_length=1)
|
|
83
|
+
labels: dict[str, Label] = Field(default_factory=dict)
|
|
84
|
+
references: dict[str, dict[str, Distribution]] = Field(default_factory=dict)
|
|
85
|
+
family: str | None = None
|
|
86
|
+
weight: float = Field(default=1.0, ge=0)
|
|
87
|
+
"""How much the record counts in a training update."""
|
|
88
|
+
|
|
89
|
+
@model_validator(mode="after")
|
|
90
|
+
def labels_and_references_match_questions(self) -> "Record":
|
|
91
|
+
check_askable(self.questions, MAX_CHOICE_OPTIONS)
|
|
92
|
+
check_images(self.state)
|
|
93
|
+
unknown = (set(self.labels) | set(self.references)) - set(self.questions)
|
|
94
|
+
if unknown:
|
|
95
|
+
raise ValueError(f"labels or references for unknown questions {sorted(unknown)}")
|
|
96
|
+
for qid, label in self.labels.items():
|
|
97
|
+
question = self.questions[qid]
|
|
98
|
+
try:
|
|
99
|
+
soft = soft_target(question, label)
|
|
100
|
+
if soft is not None:
|
|
101
|
+
self.references.setdefault(qid, {})[TARGET] = soft
|
|
102
|
+
label = label_for_key(question, max(option_keys(question), key=soft.__getitem__))
|
|
103
|
+
self.labels[qid] = canonical_label(question, label)
|
|
104
|
+
label_index(question, self.labels[qid])
|
|
105
|
+
except ValueError as error:
|
|
106
|
+
raise ValueError(f"label for {qid!r}: {error}") from error
|
|
107
|
+
for qid, sources in self.references.items():
|
|
108
|
+
keys = set(option_keys(self.questions[qid]))
|
|
109
|
+
for name, distribution in sources.items():
|
|
110
|
+
if set(distribution) != keys:
|
|
111
|
+
raise ValueError(f"reference {name!r} for {qid!r} must cover exactly {sorted(keys)}")
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Observation:
|
|
117
|
+
record: int
|
|
118
|
+
question_id: str
|
|
119
|
+
question_type: str
|
|
120
|
+
family: str | None
|
|
121
|
+
keys: list[str]
|
|
122
|
+
logits: np.ndarray | None
|
|
123
|
+
"""None when the record exceeded the engine's input limit and could not be scored."""
|
|
124
|
+
label: int | None
|
|
125
|
+
references: dict[str, np.ndarray]
|
|
126
|
+
reversed_argmax: int | None
|
|
127
|
+
latency_ms: float | None = None
|
|
128
|
+
"""Wall time for the whole record's request, on every observation the request produced."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_dataset(path: Path) -> list[Record]:
|
|
132
|
+
"""Records from a `.jsonl` file (one row per line) or a `.json` array, in ruling's row shape or a hosted platform's."""
|
|
133
|
+
text = path.read_text()
|
|
134
|
+
if path.suffix == ".json":
|
|
135
|
+
rows = json.loads(text)
|
|
136
|
+
if not isinstance(rows, list):
|
|
137
|
+
raise ValueError(f"{path}: a .json dataset must be an array of rows")
|
|
138
|
+
else:
|
|
139
|
+
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
140
|
+
records = []
|
|
141
|
+
for index, row in enumerate(rows, start=1):
|
|
142
|
+
try:
|
|
143
|
+
records.append(Record.model_validate(as_record(row)))
|
|
144
|
+
except ValueError as error:
|
|
145
|
+
where = f"row {index}" + (f" ({row['id']})" if isinstance(row, dict) and row.get("id") is not None else "")
|
|
146
|
+
raise ValueError(f"{path} {where}: {error}") from error
|
|
147
|
+
return records
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def as_record(row: dict) -> dict:
|
|
151
|
+
"""A row in any accepted shape as a record dict: a single-question row becomes one question, `decision`."""
|
|
152
|
+
if "questions" in row:
|
|
153
|
+
return row
|
|
154
|
+
if "question" not in row:
|
|
155
|
+
raise ValueError("a row needs a 'questions' map or a single 'question'")
|
|
156
|
+
label = _single_label(row)
|
|
157
|
+
record = {k: v for k, v in row.items() if k not in ("question", "label", "target", "expected")}
|
|
158
|
+
record["questions"] = {SINGLE_QUESTION: row["question"]}
|
|
159
|
+
if label is not None:
|
|
160
|
+
record["labels"] = {SINGLE_QUESTION: label}
|
|
161
|
+
return record
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _single_label(row: dict):
|
|
165
|
+
"""Precedence for a single-question row: `expected`, else a soft `target`, else `label`, else `target`."""
|
|
166
|
+
if "expected" in row:
|
|
167
|
+
return row["expected"]
|
|
168
|
+
target, label = row.get("target"), row.get("label")
|
|
169
|
+
if isinstance(target, (list, float, dict)) and not isinstance(target, bool):
|
|
170
|
+
return target
|
|
171
|
+
return label if label is not None else target
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def soft_target(question: Choice | Score | Noul, label: Label) -> Distribution | None:
|
|
175
|
+
"""A soft label as a distribution over the question's canonical keys, or None when the label is hard.
|
|
176
|
+
|
|
177
|
+
Noul takes a probability of yes or a map over true/false (or yes/no); Choice a map over its options or a
|
|
178
|
+
list in option order; Score a map over "0".."n-1" or a list from the lowest level up.
|
|
179
|
+
"""
|
|
180
|
+
keys = option_keys(question)
|
|
181
|
+
if isinstance(label, (bool, str)):
|
|
182
|
+
return None
|
|
183
|
+
if isinstance(label, dict):
|
|
184
|
+
rename = NOUL_KEYS if isinstance(question, Noul) else {}
|
|
185
|
+
probs = {rename.get(str(k), str(k)): float(v) for k, v in label.items()}
|
|
186
|
+
elif isinstance(label, list):
|
|
187
|
+
if isinstance(question, Noul):
|
|
188
|
+
raise ValueError("a noul soft label is a probability of yes or a map over true/false, not a list")
|
|
189
|
+
if len(label) != len(keys):
|
|
190
|
+
raise ValueError(f"a soft label needs {len(keys)} probabilities, got {len(label)}")
|
|
191
|
+
probs = dict(zip(keys, map(float, label)))
|
|
192
|
+
elif isinstance(question, Noul):
|
|
193
|
+
if not 0 <= label <= 1:
|
|
194
|
+
raise ValueError(f"a noul probability must be within [0, 1], got {label!r}")
|
|
195
|
+
probs = {"yes": float(label), "no": 1.0 - float(label)}
|
|
196
|
+
else:
|
|
197
|
+
return None
|
|
198
|
+
if set(probs) != set(keys):
|
|
199
|
+
raise ValueError(f"a soft label must cover exactly {keys}, got {sorted(probs)}")
|
|
200
|
+
if min(probs.values()) < 0:
|
|
201
|
+
raise ValueError("a soft label must be non-negative")
|
|
202
|
+
if abs(sum(probs.values()) - 1.0) > SOFT_SUM_TOLERANCE:
|
|
203
|
+
raise ValueError(f"a soft label must sum to 1, got {sum(probs.values()):.4f}")
|
|
204
|
+
return probs
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def canonical_label(question: Choice | Score | Noul, label: Label) -> str | int | bool:
|
|
208
|
+
"""The string forms of a hard label in ruling's form: "true"/"false" to a bool, a score id "2" to 2."""
|
|
209
|
+
if isinstance(question, Noul) and isinstance(label, str) and label.lower() in ("true", "false", "yes", "no"):
|
|
210
|
+
return label.lower() in ("true", "yes")
|
|
211
|
+
if isinstance(question, Score) and isinstance(label, str) and label.isdigit():
|
|
212
|
+
return int(label)
|
|
213
|
+
return label
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def label_for_key(question: Choice | Score | Noul, key: str) -> str | int | bool:
|
|
217
|
+
"""The hard label that names one canonical key."""
|
|
218
|
+
if isinstance(question, Noul):
|
|
219
|
+
return key == "yes"
|
|
220
|
+
if isinstance(question, Score):
|
|
221
|
+
return int(key)
|
|
222
|
+
return key
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def label_index(question: Choice | Score | Noul, label: str | int | bool) -> int:
|
|
226
|
+
keys = option_keys(question)
|
|
227
|
+
if isinstance(question, Noul):
|
|
228
|
+
if not isinstance(label, bool):
|
|
229
|
+
raise ValueError(f"noul label must be a boolean, got {label!r}")
|
|
230
|
+
return 0 if label else 1
|
|
231
|
+
if isinstance(question, Score):
|
|
232
|
+
if isinstance(label, bool):
|
|
233
|
+
raise ValueError("score label must be a level index or level text")
|
|
234
|
+
if isinstance(label, int):
|
|
235
|
+
if not 0 <= label < len(keys):
|
|
236
|
+
raise ValueError(f"score level {label} out of range")
|
|
237
|
+
return label
|
|
238
|
+
if label in question.criteria:
|
|
239
|
+
return question.criteria.index(label)
|
|
240
|
+
raise ValueError(f"unknown score level {label!r}")
|
|
241
|
+
if label not in keys:
|
|
242
|
+
raise ValueError(f"unknown choice option {label!r}")
|
|
243
|
+
return keys.index(label)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def answer_distribution(answer: dict, keys: list[str]) -> Distribution:
|
|
247
|
+
"""A published System One answer as a distribution over canonical keys."""
|
|
248
|
+
if answer["type"] == "noul":
|
|
249
|
+
return {"yes": float(answer["noul"]), "no": 1.0 - float(answer["noul"])}
|
|
250
|
+
probabilities = {str(k): float(v) for k, v in answer["probabilities"].items()}
|
|
251
|
+
missing = set(keys) - set(probabilities)
|
|
252
|
+
if missing:
|
|
253
|
+
raise ValueError(f"published answer lacks options {sorted(missing)}")
|
|
254
|
+
return {key: probabilities[key] for key in keys}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def collect(engine, records: list[Record], extra_references: dict[tuple[int, str], Distribution] | None = None) -> list[Observation]:
|
|
258
|
+
"""Score every record. Choice questions are also scored with reversed options.
|
|
259
|
+
|
|
260
|
+
A record longer than the engine accepts becomes unscored observations, so a
|
|
261
|
+
report can say how much of a dataset a model could read at all.
|
|
262
|
+
"""
|
|
263
|
+
observations = []
|
|
264
|
+
for index, record in enumerate(records):
|
|
265
|
+
started = time.perf_counter()
|
|
266
|
+
try:
|
|
267
|
+
scored = engine.score(record.state, record.questions)
|
|
268
|
+
except InputTooLong:
|
|
269
|
+
observations += [Observation(record=index, question_id=qid, question_type=q.type, family=record.family,
|
|
270
|
+
keys=option_keys(q), logits=None,
|
|
271
|
+
label=label_index(q, record.labels[qid]) if qid in record.labels else None,
|
|
272
|
+
references={}, reversed_argmax=None)
|
|
273
|
+
for qid, q in record.questions.items()]
|
|
274
|
+
continue
|
|
275
|
+
reversed_questions = {
|
|
276
|
+
qid: Choice(instructions=q.instructions, criteria=dict(reversed(list(q.criteria.items()))))
|
|
277
|
+
for qid, q in record.questions.items()
|
|
278
|
+
if isinstance(q, Choice)
|
|
279
|
+
}
|
|
280
|
+
latency_ms = (time.perf_counter() - started) * 1000
|
|
281
|
+
reversed_scored = engine.score(record.state, reversed_questions) if reversed_questions else None
|
|
282
|
+
for qid, question in record.questions.items():
|
|
283
|
+
raw = scored.raw[qid]
|
|
284
|
+
reversed_argmax = None
|
|
285
|
+
if qid in reversed_questions:
|
|
286
|
+
rev = reversed_scored.raw[qid]
|
|
287
|
+
reversed_argmax = raw.keys.index(rev.keys[int(rev.logits.argmax())])
|
|
288
|
+
references = dict(record.references.get(qid, {}))
|
|
289
|
+
if extra_references and (index, qid) in extra_references:
|
|
290
|
+
references["live"] = extra_references[(index, qid)]
|
|
291
|
+
observations.append(
|
|
292
|
+
Observation(
|
|
293
|
+
record=index,
|
|
294
|
+
question_id=qid,
|
|
295
|
+
question_type=question.type,
|
|
296
|
+
family=record.family,
|
|
297
|
+
keys=raw.keys,
|
|
298
|
+
logits=raw.logits,
|
|
299
|
+
label=label_index(question, record.labels[qid]) if qid in record.labels else None,
|
|
300
|
+
references={name: np.array([d[k] for k in raw.keys]) for name, d in references.items()},
|
|
301
|
+
reversed_argmax=reversed_argmax,
|
|
302
|
+
latency_ms=latency_ms,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
return observations
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def balanced_accuracy(predicted: list[int], gold: list[int]) -> float:
|
|
309
|
+
recalls = []
|
|
310
|
+
for label in sorted(set(gold)):
|
|
311
|
+
hits = sum(1 for p, g in zip(predicted, gold) if g == label and p == label)
|
|
312
|
+
recalls.append(hits / gold.count(label))
|
|
313
|
+
return float(np.mean(recalls))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def total_variation(p: np.ndarray, q: np.ndarray) -> float:
|
|
317
|
+
return float(0.5 * np.abs(p - q).sum())
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def report(observations: list[Observation], calibration: Calibration) -> dict:
|
|
321
|
+
"""Per-type summaries under `question_types`, plus the pooled decision metrics, by type and by question."""
|
|
322
|
+
by_type: dict[str, list[Observation]] = defaultdict(list)
|
|
323
|
+
for obs in observations:
|
|
324
|
+
by_type[obs.question_type].append(obs)
|
|
325
|
+
result = {"question_types": {}, **decision_blocks(observations, calibration)}
|
|
326
|
+
latencies = sorted({(o.record, o.latency_ms) for o in observations if o.latency_ms is not None})
|
|
327
|
+
if latencies:
|
|
328
|
+
values = np.array([ms for _, ms in latencies])
|
|
329
|
+
result["latency_ms"] = {"p50": float(np.percentile(values, 50)), "p95": float(np.percentile(values, 95)),
|
|
330
|
+
"records": len(values)}
|
|
331
|
+
for question_type, everything in by_type.items():
|
|
332
|
+
group = [o for o in everything if o.logits is not None]
|
|
333
|
+
if not group:
|
|
334
|
+
result["question_types"][question_type] = {"count": 0, "unscored": len(everything)}
|
|
335
|
+
continue
|
|
336
|
+
temperature = calibration.temperature(question_type)
|
|
337
|
+
probs = [softmax(o.logits, temperature) for o in group]
|
|
338
|
+
summary = {"count": len(group), "unscored": len(everything) - len(group), "temperature": temperature,
|
|
339
|
+
"mean_confidence": float(np.mean([choice_confidence(p) for p in probs]))}
|
|
340
|
+
labeled = [(o, p) for o, p in zip(group, probs) if o.label is not None]
|
|
341
|
+
summary["labeled_count"] = len(labeled)
|
|
342
|
+
if labeled:
|
|
343
|
+
predicted = [int(p.argmax()) for _, p in labeled]
|
|
344
|
+
gold = [o.label for o, _ in labeled]
|
|
345
|
+
correct = [a == b for a, b in zip(predicted, gold)]
|
|
346
|
+
summary.update({
|
|
347
|
+
"accuracy": float(np.mean(correct)),
|
|
348
|
+
"balanced_accuracy": balanced_accuracy(predicted, gold),
|
|
349
|
+
"expected_calibration_error": expected_calibration_error([float(p.max()) for _, p in labeled], correct),
|
|
350
|
+
"brier": float(np.mean([((p - np.eye(len(p))[g]) ** 2).sum() for (_, p), g in zip(labeled, gold)])),
|
|
351
|
+
"negative_log_likelihood": float(np.mean([-np.log(max(p[g], 1e-12)) for (_, p), g in zip(labeled, gold)])),
|
|
352
|
+
"coverage_at_5pct_error": coverage_at_error([float(p.max()) for _, p in labeled], correct, ERROR_BUDGET),
|
|
353
|
+
"confident_error_rate": confident_error_rate([float(p.max()) for _, p in labeled], correct),
|
|
354
|
+
})
|
|
355
|
+
families = defaultdict(list)
|
|
356
|
+
for (o, _), p, g in zip(labeled, predicted, gold):
|
|
357
|
+
if o.family is not None:
|
|
358
|
+
families[o.family].append((p, g))
|
|
359
|
+
if families and len(families) == len({o.family for o, _ in labeled}):
|
|
360
|
+
per_family = {name: balanced_accuracy([p for p, _ in pairs], [g for _, g in pairs])
|
|
361
|
+
for name, pairs in families.items()}
|
|
362
|
+
summary["family_balanced_accuracy"] = per_family
|
|
363
|
+
summary["mean_family_balanced_accuracy"] = float(np.mean(list(per_family.values())))
|
|
364
|
+
if question_type == "choice":
|
|
365
|
+
flips = sum(1 for o, p in zip(group, probs) if o.reversed_argmax != int(p.argmax()))
|
|
366
|
+
summary["option_reversal_flips"] = flips
|
|
367
|
+
summary["option_reversal_flip_rate"] = flips / len(group)
|
|
368
|
+
names = sorted({name for o in group for name in o.references})
|
|
369
|
+
if names:
|
|
370
|
+
summary["references"] = {name: _reference_summary(name, group, probs) for name in names}
|
|
371
|
+
result["question_types"][question_type] = summary
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def decision_blocks(observations: list[Observation], calibration: Calibration) -> dict:
|
|
376
|
+
"""`decision_metrics` on every labeled, scored question: pooled, keyed by type, and keyed by question id.
|
|
377
|
+
|
|
378
|
+
The target is the record's soft `target` reference where it has one, else one-hot on the label.
|
|
379
|
+
"""
|
|
380
|
+
labeled = [o for o in observations if o.logits is not None and o.label is not None]
|
|
381
|
+
probs = [softmax(o.logits, calibration.temperature(o.question_type)) for o in labeled]
|
|
382
|
+
targets = [o.references[TARGET] if TARGET in o.references else np.eye(len(o.keys))[o.label] for o in labeled]
|
|
383
|
+
|
|
384
|
+
def block(select) -> dict:
|
|
385
|
+
chosen = [i for i, o in enumerate(labeled) if select(o)]
|
|
386
|
+
return decision_metrics([probs[i] for i in chosen], [targets[i] for i in chosen])
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"overall": block(lambda o: True),
|
|
390
|
+
"by_type": {t: block(lambda o: o.question_type == t) for t in dict.fromkeys(o.question_type for o in labeled)},
|
|
391
|
+
"by_question": {q: block(lambda o: o.question_id == q) for q in dict.fromkeys(o.question_id for o in labeled)},
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _reference_summary(name: str, group: list[Observation], probs: list[np.ndarray]) -> dict:
|
|
396
|
+
pairs = [(o, p, o.references[name]) for o, p in zip(group, probs) if name in o.references]
|
|
397
|
+
summary = {
|
|
398
|
+
"count": len(pairs),
|
|
399
|
+
"argmax_agreement": float(np.mean([int(p.argmax()) == int(r.argmax()) for _, p, r in pairs])),
|
|
400
|
+
"mean_total_variation": float(np.mean([total_variation(p, r) for _, p, r in pairs])),
|
|
401
|
+
}
|
|
402
|
+
labeled = [(o, p, r) for o, p, r in pairs if o.label is not None]
|
|
403
|
+
if labeled:
|
|
404
|
+
summary["labeled_count"] = len(labeled)
|
|
405
|
+
summary["reference_accuracy"] = float(np.mean([int(r.argmax()) == o.label for o, _, r in labeled]))
|
|
406
|
+
summary["ruling_accuracy_on_same_rows"] = float(np.mean([int(p.argmax()) == o.label for o, p, _ in labeled]))
|
|
407
|
+
return summary
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def fit(observations: list[Observation], provenance: Provenance) -> Calibration:
|
|
411
|
+
by_type: dict[str, list[Observation]] = defaultdict(list)
|
|
412
|
+
for obs in observations:
|
|
413
|
+
if obs.label is not None and obs.logits is not None:
|
|
414
|
+
by_type[obs.question_type].append(obs)
|
|
415
|
+
if not by_type:
|
|
416
|
+
raise ValueError("cannot fit a calibration without labeled questions")
|
|
417
|
+
return Calibration(
|
|
418
|
+
temperatures={
|
|
419
|
+
question_type: fit_temperature([o.logits for o in group], [o.label for o in group])
|
|
420
|
+
for question_type, group in by_type.items()
|
|
421
|
+
},
|
|
422
|
+
provenance=provenance,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def write_predictions(observations: list[Observation], calibration: Calibration, path: Path) -> None:
|
|
427
|
+
"""One line per scored question with its calibrated distribution, for ensembling and audits."""
|
|
428
|
+
with path.open("w") as out:
|
|
429
|
+
for obs in observations:
|
|
430
|
+
if obs.logits is None:
|
|
431
|
+
continue
|
|
432
|
+
probs = softmax(obs.logits, calibration.temperature(obs.question_type))
|
|
433
|
+
out.write(json.dumps({"record": obs.record, "question_id": obs.question_id, "type": obs.question_type,
|
|
434
|
+
"keys": obs.keys, "probabilities": [float(p) for p in probs], "label": obs.label}) + "\n")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def predict(engine, records: list[Record]) -> Iterator[dict]:
|
|
438
|
+
"""One line per record, in order: its answers as the API returns them, or the API's error for a row it refuses.
|
|
439
|
+
|
|
440
|
+
Labels are ignored. A refused row keeps its place, so line i always belongs to record i.
|
|
441
|
+
"""
|
|
442
|
+
for record in records:
|
|
443
|
+
line = {"id": record.id} if record.id is not None else {}
|
|
444
|
+
try:
|
|
445
|
+
check_askable(record.questions, engine.max_options)
|
|
446
|
+
check_readable(record.state, engine.reads_images)
|
|
447
|
+
response = engine.evaluate(SystemOneRequest(state=record.state, questions=record.questions))
|
|
448
|
+
line["answers"] = {qid: a.model_dump(mode="json") for qid, a in response.answers.items()}
|
|
449
|
+
except InputTooLong as exc:
|
|
450
|
+
line["error"] = {"code": "context_too_long", "message": str(exc)}
|
|
451
|
+
except InvalidOptions as exc:
|
|
452
|
+
line["error"] = {"code": "invalid_options", "message": str(exc)}
|
|
453
|
+
except UnsupportedModality as exc:
|
|
454
|
+
line["error"] = {"code": "unsupported_modality", "message": str(exc)}
|
|
455
|
+
yield line
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def write_json(path: Path, data: dict) -> None:
|
|
459
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
ruling/failover.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""One engine answers; another takes over whenever the first is unavailable.
|
|
2
|
+
|
|
3
|
+
Built for a hosted primary such as `typesafe:jev-latest` with a local model
|
|
4
|
+
behind it: a 503, a 429 or a timeout from the host is answered by the fallback
|
|
5
|
+
instead of failing the request. Which engine answered is in the response's
|
|
6
|
+
`model`, so a caller can tell a fallback answer from a primary one.
|
|
7
|
+
|
|
8
|
+
Only unavailability triggers the fallback. A refusal (413, 400) or a bug is
|
|
9
|
+
raised as it would be from the primary alone.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from ruling.calibration import Provenance
|
|
17
|
+
from ruling.engine import RawScore, Scored
|
|
18
|
+
from ruling.hosted import HostUnavailable
|
|
19
|
+
from ruling.questions import Choice, Noul, Score, State, SystemOneRequest, SystemOneResponse, Usage
|
|
20
|
+
|
|
21
|
+
PREFIX = "failover:"
|
|
22
|
+
log = logging.getLogger("ruling")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FailoverEngine:
|
|
26
|
+
"""Same interface as the engines it wraps; limits are the primary's, since the fallback is best effort."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, primary, fallback):
|
|
29
|
+
self.primary, self.fallback = primary, fallback
|
|
30
|
+
self.model_id = f"{PREFIX}{primary.model_id}>{fallback.model_id}"
|
|
31
|
+
self.revision = primary.revision
|
|
32
|
+
self.adapter = primary.adapter
|
|
33
|
+
self.rotations = primary.rotations
|
|
34
|
+
self.prior_debias = primary.prior_debias
|
|
35
|
+
self.max_input_tokens = primary.max_input_tokens
|
|
36
|
+
self.max_branch_tokens = primary.max_branch_tokens
|
|
37
|
+
self.max_options = primary.max_options
|
|
38
|
+
self.reads_images = primary.reads_images
|
|
39
|
+
self.calibration = primary.calibration
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def provenance(self) -> Provenance:
|
|
43
|
+
return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
|
|
44
|
+
|
|
45
|
+
def _answering(self, state: State, questions: dict[str, Choice | Score | Noul]):
|
|
46
|
+
"""The engine that answered, and its scores."""
|
|
47
|
+
try:
|
|
48
|
+
return self.primary, self.primary.score(state, questions)
|
|
49
|
+
except HostUnavailable as exc:
|
|
50
|
+
log.warning("failover from %s to %s: %s", self.primary.model_id, self.fallback.model_id, exc)
|
|
51
|
+
return self.fallback, self.fallback.score(state, questions)
|
|
52
|
+
|
|
53
|
+
def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
54
|
+
return self._answering(state, questions)[1]
|
|
55
|
+
|
|
56
|
+
def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
|
|
57
|
+
engine, scored = self._answering(request.state, request.questions)
|
|
58
|
+
answers = {qid: engine.answer(request.questions[qid], scored.raw[qid]) for qid in request.questions}
|
|
59
|
+
return SystemOneResponse(model=engine.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens))
|
|
60
|
+
|
|
61
|
+
def answer(self, question, raw: RawScore):
|
|
62
|
+
return self.primary.answer(question, raw)
|
ruling/history.html
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ruling retraining</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg: #fafaf7; --fg: #1b1b1b; --muted: #6b6b6b; --line: #dcdcd5; --bar: #2f6f4f; --accent: #b3451f; }
|
|
9
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #161615; --fg: #ecece6; --muted: #9a9a92; --line: #33332f; --bar: #5fae85; --accent: #e0774d; } }
|
|
10
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
11
|
+
main { max-width: 960px; margin: 0 auto; padding: 24px 16px; display: grid; gap: 16px; }
|
|
12
|
+
h1 { margin: 0; font-size: 18px; }
|
|
13
|
+
h1 small, .meta { color: var(--muted); font-weight: normal; font-size: 12px; }
|
|
14
|
+
h1 small { margin-left: 8px; }
|
|
15
|
+
a { color: inherit; }
|
|
16
|
+
h2 { margin: 8px 0 4px; font-size: 15px; }
|
|
17
|
+
.scroll { overflow-x: auto; }
|
|
18
|
+
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
19
|
+
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
20
|
+
th { color: var(--muted); font-weight: normal; font-size: 12px; }
|
|
21
|
+
td.num { text-align: right; white-space: nowrap; }
|
|
22
|
+
.promoted { color: var(--bar); }
|
|
23
|
+
.kept { color: var(--muted); }
|
|
24
|
+
.serving { font-weight: bold; }
|
|
25
|
+
.err { color: var(--accent); }
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<main>
|
|
30
|
+
<h1>ruling <small>retraining history · <a href="/">playground</a></small></h1>
|
|
31
|
+
<div id="out" class="meta">loading…</div>
|
|
32
|
+
</main>
|
|
33
|
+
<script>
|
|
34
|
+
const out = document.getElementById("out");
|
|
35
|
+
const pct = x => x == null ? "–" : x.toFixed(3);
|
|
36
|
+
function change(current, candidate, key) {
|
|
37
|
+
return `${pct(current[key])} → ${pct(candidate[key])}`;
|
|
38
|
+
}
|
|
39
|
+
function text(value) {
|
|
40
|
+
const span = document.createElement("span");
|
|
41
|
+
span.textContent = value;
|
|
42
|
+
return span.innerHTML;
|
|
43
|
+
}
|
|
44
|
+
let key = "";
|
|
45
|
+
try { key = sessionStorage.getItem("ruling-key") || ""; } catch {}
|
|
46
|
+
fetch("/v1/retrain", { headers: key ? { authorization: `Bearer ${key}` } : {} }).then(async r => {
|
|
47
|
+
const data = await r.json();
|
|
48
|
+
if (r.status === 401) throw new Error("this server needs its API key; enter it in the playground first");
|
|
49
|
+
if (!r.ok) throw new Error(`${r.status} ${data.error.code}: ${data.error.message}`);
|
|
50
|
+
return data;
|
|
51
|
+
}).then(data => {
|
|
52
|
+
if (!data.models.length) {
|
|
53
|
+
out.innerHTML = "No served model is a <code>ruling retrain</code> adapter. Serve one with " +
|
|
54
|
+
"<code>RULING_ADAPTER=<adapters>/current</code>.";
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let html = "";
|
|
58
|
+
for (const m of data.models) {
|
|
59
|
+
html += `<h2>${text(m.name)} <span class="meta">serving ${text(m.current ?? "the base model")}</span></h2>`;
|
|
60
|
+
if (!m.versions.length) { html += '<div class="meta">no finished runs yet</div>'; continue; }
|
|
61
|
+
html += '<div class="scroll"><table><thead><tr><th>version</th><th>from</th><th class="num">records</th>' +
|
|
62
|
+
'<th class="num">held-out accuracy</th><th class="num">held-out log loss</th><th>verdict</th></tr></thead><tbody>';
|
|
63
|
+
for (const v of [...m.versions].reverse()) {
|
|
64
|
+
const serving = v.version === m.current ? " serving" : "";
|
|
65
|
+
html += `<tr class="${v.promoted ? "promoted" : "kept"}${serving}" data-version="${text(v.version)}">` +
|
|
66
|
+
`<td>${text(v.version)}</td><td>${text(v.started_from ?? "base")}</td>` +
|
|
67
|
+
`<td class="num">${v.records}</td><td class="num">${change(v.current, v.candidate, "accuracy")}</td>` +
|
|
68
|
+
`<td class="num">${change(v.current, v.candidate, "nll")}</td>` +
|
|
69
|
+
`<td>${v.promoted ? "promoted" : "kept"}: ${text(v.reason)}</td></tr>`;
|
|
70
|
+
}
|
|
71
|
+
html += "</tbody></table></div>";
|
|
72
|
+
}
|
|
73
|
+
out.className = "";
|
|
74
|
+
out.innerHTML = html;
|
|
75
|
+
}).catch(e => { out.className = "err"; out.textContent = `could not load the history: ${e.message}`; });
|
|
76
|
+
</script>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|