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/hosted.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Models served behind an OpenAI-compatible chat endpoint.
|
|
2
|
+
|
|
3
|
+
Questions are rendered exactly as the local decoder engine renders them, and
|
|
4
|
+
the answer is read from the first token's logprobs, restricted to the option
|
|
5
|
+
letters, so a hosted model and a local one are directly comparable. Each
|
|
6
|
+
question is asked once per option ordering, all calls for a record in parallel.
|
|
7
|
+
|
|
8
|
+
`HostedEngine` speaks to any server that implements `/chat/completions` with
|
|
9
|
+
`top_logprobs`: vLLM, SGLang, llama.cpp, LM Studio, mlx_lm.server, or a
|
|
10
|
+
commercial API. `OpenRouterEngine` adds what only OpenRouter offers, provider
|
|
11
|
+
pinning and per-response cost, so a run can stop at a budget.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from ruling.calibration import Calibration, Provenance
|
|
26
|
+
from ruling.engine import InputTooLong, RawScore, Scored, TwoStageChoice, answer
|
|
27
|
+
from ruling.prompt import SYSTEM_PROMPT, hosted_user_message, render_question
|
|
28
|
+
from ruling.evals import answer_distribution
|
|
29
|
+
from ruling.questions import (MAX_CHOICE_OPTIONS, MAX_QUESTIONS, Choice, InvalidOptions, Noul, Score, State, SystemOneRequest,
|
|
30
|
+
SystemOneResponse, Usage, option_keys)
|
|
31
|
+
|
|
32
|
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
33
|
+
OPENROUTER_PREFIX = "openrouter:"
|
|
34
|
+
HOSTED_PREFIX = "openai:"
|
|
35
|
+
LETTERS = [chr(ord("A") + i) for i in range(20)]
|
|
36
|
+
TOP_LOGPROBS = 20
|
|
37
|
+
"""How many top logprobs a host will return, and so how many options one pass can read.
|
|
38
|
+
|
|
39
|
+
Hosts differ: OpenAI and OpenRouter allow 20, `mlx_lm.server` allows 11, vLLM is
|
|
40
|
+
configurable. A choice with more options than that is narrowed in two stages, so
|
|
41
|
+
every letter a pass reads can appear in what the host returns.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HostUnavailable(RuntimeError):
|
|
46
|
+
"""The host is up but not answering right now: overloaded, rate limited, or timing out.
|
|
47
|
+
|
|
48
|
+
Distinct from a refusal or a bug so a wrapper can fall back to another engine
|
|
49
|
+
instead of failing the request.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
UNAVAILABLE_STATUSES = (429, 502, 503, 504)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unavailable_or_error(model_id: str, response: httpx.Response) -> RuntimeError:
|
|
57
|
+
"""The exception for a non-200 host response."""
|
|
58
|
+
text = response.text[:500]
|
|
59
|
+
if response.status_code in UNAVAILABLE_STATUSES:
|
|
60
|
+
return HostUnavailable(f"{model_id} is unavailable, returned {response.status_code}: {text}")
|
|
61
|
+
return RuntimeError(f"{model_id} returned {response.status_code}: {text}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BudgetExceeded(RuntimeError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Budget:
|
|
70
|
+
limit_usd: float
|
|
71
|
+
spent_usd: float = 0.0
|
|
72
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
73
|
+
|
|
74
|
+
def charge(self, usd: float) -> None:
|
|
75
|
+
with self._lock:
|
|
76
|
+
self.spent_usd += usd
|
|
77
|
+
|
|
78
|
+
def check(self) -> None:
|
|
79
|
+
if self.spent_usd > self.limit_usd:
|
|
80
|
+
raise BudgetExceeded(f"spent ${self.spent_usd:.4f} of a ${self.limit_usd:.2f} budget")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def letter_log_probs(top_logprobs: list[dict], letters: list[str]) -> list[float]:
|
|
84
|
+
"""Normalized log-probabilities over `letters` from one token's top logprobs.
|
|
85
|
+
|
|
86
|
+
Spacing variants such as " A" count as "A". A letter absent from the top
|
|
87
|
+
list gets half the probability of the least likely finite token returned, an
|
|
88
|
+
upper bound on its true mass, before renormalizing. All arithmetic stays in
|
|
89
|
+
log space because hosts report logprobs as low as -9999.
|
|
90
|
+
"""
|
|
91
|
+
finite = [e for e in top_logprobs if math.isfinite(e["logprob"])]
|
|
92
|
+
grouped: dict[str, list[float]] = {letter: [] for letter in letters}
|
|
93
|
+
for entry in finite:
|
|
94
|
+
token = entry["token"].strip()
|
|
95
|
+
if token in grouped:
|
|
96
|
+
grouped[token].append(entry["logprob"])
|
|
97
|
+
if not any(grouped.values()):
|
|
98
|
+
raise ValueError("none of the answer letters appear in the top logprobs")
|
|
99
|
+
floor = min(e["logprob"] for e in finite) - math.log(2)
|
|
100
|
+
values = [_logsumexp(grouped[letter]) if grouped[letter] else floor for letter in letters]
|
|
101
|
+
total = _logsumexp(values)
|
|
102
|
+
return [v - total for v in values]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _logsumexp(values: list[float]) -> float:
|
|
106
|
+
top = max(values)
|
|
107
|
+
return top + math.log(sum(math.exp(v - top) for v in values))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def first_token_top_logprobs(data: dict) -> list[dict] | None:
|
|
111
|
+
"""The first generated token's top logprobs, or None when the host returned none."""
|
|
112
|
+
logprobs = data["choices"][0].get("logprobs")
|
|
113
|
+
content = (logprobs or {}).get("content") or []
|
|
114
|
+
return content[0]["top_logprobs"] if content else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
MISSING_LOGPROB_ATTEMPTS = 3
|
|
118
|
+
"""Hosts that advertise logprobs occasionally answer without them under load; the request is repeated this many times."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class HostedEngine(TwoStageChoice):
|
|
122
|
+
"""Scores typed questions with a model behind an OpenAI-compatible endpoint.
|
|
123
|
+
|
|
124
|
+
Same interface as the local engines, so the server, the evaluation harness
|
|
125
|
+
and the calibration fitter cannot tell the difference.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
prior_debias = False
|
|
129
|
+
prefix = HOSTED_PREFIX
|
|
130
|
+
|
|
131
|
+
def __init__(self, model: str, calibration: Calibration, rotations: int, base_url: str,
|
|
132
|
+
api_key: str | None = None, concurrency: int = 16, top_logprobs: int = TOP_LOGPROBS,
|
|
133
|
+
extra_body: dict | None = None):
|
|
134
|
+
self.remote = model.removeprefix(self.prefix)
|
|
135
|
+
self.model_id = self.prefix + self.remote
|
|
136
|
+
self.base_url = base_url.rstrip("/")
|
|
137
|
+
self.revision = None
|
|
138
|
+
self.adapter = None
|
|
139
|
+
self.rotations = rotations
|
|
140
|
+
self.top_logprobs = min(top_logprobs, len(LETTERS))
|
|
141
|
+
# Hosts disable reasoning differently: vLLM and mlx_lm.server take
|
|
142
|
+
# chat_template_kwargs, OpenRouter takes `reasoning`. Nothing is sent by
|
|
143
|
+
# default, because OpenAI rejects body fields it does not know.
|
|
144
|
+
self.extra_body = extra_body or {}
|
|
145
|
+
self.pass_options = self.top_logprobs
|
|
146
|
+
self.max_options = MAX_CHOICE_OPTIONS
|
|
147
|
+
# Images go to the host as content parts; a host whose model reads only text refuses them itself.
|
|
148
|
+
self.reads_images = True
|
|
149
|
+
self.concurrency = concurrency
|
|
150
|
+
# A hosted model's context is the host's business; ruling does not re-impose one.
|
|
151
|
+
self.max_input_tokens = 1_000_000
|
|
152
|
+
self.max_branch_tokens = self.max_input_tokens
|
|
153
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
154
|
+
self._client = httpx.Client(headers=headers, timeout=120)
|
|
155
|
+
self._pool = ThreadPoolExecutor(max_workers=concurrency)
|
|
156
|
+
calibration.check(self.provenance)
|
|
157
|
+
self.calibration = calibration
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def provenance(self) -> Provenance:
|
|
161
|
+
return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
|
|
162
|
+
|
|
163
|
+
def body(self, messages: list[dict], max_tokens: int) -> dict:
|
|
164
|
+
return {"model": self.remote, "messages": messages, "max_tokens": max_tokens,
|
|
165
|
+
"temperature": 0} | self.extra_body
|
|
166
|
+
|
|
167
|
+
def charge(self, data: dict) -> None:
|
|
168
|
+
"""Hook for hosts that report a per-response price."""
|
|
169
|
+
|
|
170
|
+
def complete(self, messages: list[dict], max_tokens: int, logprobs: bool) -> dict:
|
|
171
|
+
body = self.body(messages, max_tokens)
|
|
172
|
+
if logprobs:
|
|
173
|
+
body |= {"logprobs": True, "top_logprobs": self.top_logprobs}
|
|
174
|
+
try:
|
|
175
|
+
response = self._client.post(f"{self.base_url}/chat/completions", json=body)
|
|
176
|
+
except httpx.TransportError as exc:
|
|
177
|
+
raise HostUnavailable(f"{self.model_id} is unreachable at {self.base_url}: {exc}") from exc
|
|
178
|
+
if response.status_code != 200:
|
|
179
|
+
raise unavailable_or_error(f"{self.base_url} ({self.remote})", response)
|
|
180
|
+
data = response.json()
|
|
181
|
+
self.charge(data)
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
def _ask(self, state: State, question: Choice | Score | Noul, rotation: int) -> tuple[list[str], list[float], int]:
|
|
185
|
+
rendered = render_question(question, LETTERS, rotation)
|
|
186
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT},
|
|
187
|
+
{"role": "user", "content": hosted_user_message(state, rendered)}]
|
|
188
|
+
for _ in range(MISSING_LOGPROB_ATTEMPTS):
|
|
189
|
+
data = self.complete(messages, max_tokens=1, logprobs=True)
|
|
190
|
+
top = first_token_top_logprobs(data)
|
|
191
|
+
if top is not None:
|
|
192
|
+
return rendered.keys, letter_log_probs(top, rendered.codes), int(data["usage"]["prompt_tokens"])
|
|
193
|
+
raise RuntimeError(f"{self.remote} returned no logprobs in {MISSING_LOGPROB_ATTEMPTS} attempts (last host {data.get('provider')})")
|
|
194
|
+
|
|
195
|
+
def _score_plain(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
196
|
+
jobs = [(qid, rotation) for qid, question in questions.items()
|
|
197
|
+
for rotation in range(2 if isinstance(question, Score) else len(option_keys(question)))
|
|
198
|
+
if rotation < self.rotations]
|
|
199
|
+
results = list(self._pool.map(lambda job: (job[0], self._ask(state, questions[job[0]], job[1])), jobs))
|
|
200
|
+
per_question: dict[str, dict[str, list[float]]] = {qid: {} for qid in questions}
|
|
201
|
+
tokens = 0
|
|
202
|
+
for qid, (keys, log_probs, prompt_tokens) in results:
|
|
203
|
+
tokens += prompt_tokens
|
|
204
|
+
for key, value in zip(keys, log_probs):
|
|
205
|
+
per_question[qid].setdefault(key, []).append(value)
|
|
206
|
+
raw = {qid: RawScore(keys=option_keys(q), logits=np.array([np.mean(per_question[qid][k]) for k in option_keys(q)]))
|
|
207
|
+
for qid, q in questions.items()}
|
|
208
|
+
return Scored(raw=raw, input_tokens=tokens)
|
|
209
|
+
|
|
210
|
+
def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
|
|
211
|
+
scored = self.score(request.state, request.questions)
|
|
212
|
+
answers = {qid: answer(request.questions[qid], scored.raw[qid], self.calibration) for qid in request.questions}
|
|
213
|
+
return SystemOneResponse(model=self.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens))
|
|
214
|
+
|
|
215
|
+
def answer(self, question, raw: RawScore):
|
|
216
|
+
return answer(question, raw, self.calibration)
|
|
217
|
+
|
|
218
|
+
def write(self, prompts: list[list[dict]], max_tokens: int) -> list[str]:
|
|
219
|
+
"""Plain text completions for several prompts in parallel, used to write questions."""
|
|
220
|
+
return list(self._pool.map(lambda messages: self.complete(messages, max_tokens, logprobs=False)["choices"][0]["message"]["content"] or "",
|
|
221
|
+
prompts))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class OpenRouterEngine(HostedEngine):
|
|
225
|
+
"""OpenRouter, which pins the serving provider and prices every response."""
|
|
226
|
+
|
|
227
|
+
prefix = OPENROUTER_PREFIX
|
|
228
|
+
|
|
229
|
+
def __init__(self, model: str, calibration: Calibration, rotations: int, providers: list[str],
|
|
230
|
+
budget: Budget, concurrency: int = 16, api_key: str | None = None):
|
|
231
|
+
self.providers = providers
|
|
232
|
+
self.budget = budget
|
|
233
|
+
super().__init__(model, calibration, rotations, OPENROUTER_URL.removesuffix("/chat/completions"),
|
|
234
|
+
api_key or os.environ["OPENROUTER_API_KEY"], concurrency)
|
|
235
|
+
|
|
236
|
+
def body(self, messages: list[dict], max_tokens: int) -> dict:
|
|
237
|
+
self.budget.check()
|
|
238
|
+
return super().body(messages, max_tokens) | {
|
|
239
|
+
"reasoning": {"enabled": False}, "usage": {"include": True},
|
|
240
|
+
"provider": {"only": self.providers, "require_parameters": True},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def charge(self, data: dict) -> None:
|
|
244
|
+
self.budget.charge(float(data["usage"]["cost"]))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
TYPESAFE_PREFIX = "typesafe:"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _error_of(response: httpx.Response) -> dict:
|
|
251
|
+
"""The `error` object of a hosted-shape error body, or an empty dict for any other body."""
|
|
252
|
+
try:
|
|
253
|
+
error = response.json().get("error")
|
|
254
|
+
except ValueError:
|
|
255
|
+
return {}
|
|
256
|
+
return error if isinstance(error, dict) else {}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _is_context_rejection(response: httpx.Response) -> bool:
|
|
260
|
+
"""A host that limits context per question says 422 `context_too_long` rather than 413."""
|
|
261
|
+
return response.status_code == 422 and _error_of(response).get("code") == "context_too_long"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _question_at_fault(response: httpx.Response) -> str | None:
|
|
265
|
+
param = _error_of(response).get("param")
|
|
266
|
+
return param.removeprefix("questions.") if isinstance(param, str) and param.startswith("questions.") else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TypeSafeEngine:
|
|
270
|
+
"""TypeSafe's hosted API, or any server that speaks its protocol, as an engine.
|
|
271
|
+
|
|
272
|
+
The host returns probabilities, so the logits handed back are their logs and
|
|
273
|
+
the host's own calibration is what you get. Useful for scoring a dataset
|
|
274
|
+
against the hosted model with the same harness, and as the second stage of
|
|
275
|
+
a cascade, where a local model answers what it is sure of and only the
|
|
276
|
+
doubtful questions are paid for.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
prior_debias = False
|
|
280
|
+
max_options = MAX_CHOICE_OPTIONS
|
|
281
|
+
# The state goes to the service unchanged, images included; the service decides what it reads.
|
|
282
|
+
reads_images = True
|
|
283
|
+
prefix = TYPESAFE_PREFIX
|
|
284
|
+
rotations = 1
|
|
285
|
+
"""The host orders options as it sees fit; ruling does not re-ask it."""
|
|
286
|
+
|
|
287
|
+
def __init__(self, model: str, calibration: Calibration, base_url: str, api_key: str | None,
|
|
288
|
+
max_input_tokens: int, max_branch_tokens: int, client: httpx.Client | None = None):
|
|
289
|
+
self.remote = model.removeprefix(self.prefix)
|
|
290
|
+
self.model_id = self.prefix + self.remote
|
|
291
|
+
self.revision = None
|
|
292
|
+
self.adapter = None
|
|
293
|
+
self.max_input_tokens = max_input_tokens
|
|
294
|
+
self.max_branch_tokens = max_branch_tokens
|
|
295
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
296
|
+
self._client = client or httpx.Client(base_url=base_url.rstrip("/"), headers=headers, timeout=60)
|
|
297
|
+
calibration.check(self.provenance)
|
|
298
|
+
self.calibration = calibration
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def provenance(self) -> Provenance:
|
|
302
|
+
return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
|
|
303
|
+
|
|
304
|
+
def refusal(self, response: httpx.Response) -> RuntimeError:
|
|
305
|
+
"""The exception for a non-200 answer other than the host's context limit."""
|
|
306
|
+
return unavailable_or_error(self.model_id, response)
|
|
307
|
+
|
|
308
|
+
def _check_width(self, questions: dict[str, Choice | Score | Noul]) -> None:
|
|
309
|
+
for qid, question in questions.items():
|
|
310
|
+
if len(option_keys(question)) > self.max_options:
|
|
311
|
+
raise InvalidOptions(qid, f"a choice needs 2 to {self.max_options} options on {self.model_id}, "
|
|
312
|
+
f"got {len(option_keys(question))}")
|
|
313
|
+
|
|
314
|
+
def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
315
|
+
self._check_width(questions)
|
|
316
|
+
body = {"state": state, "questions": {qid: q.model_dump(exclude_none=True) for qid, q in questions.items()}}
|
|
317
|
+
if self.remote:
|
|
318
|
+
body["model"] = self.remote
|
|
319
|
+
try:
|
|
320
|
+
response = self._client.post("/v1/systemone", json=body)
|
|
321
|
+
except httpx.TransportError as exc:
|
|
322
|
+
raise HostUnavailable(f"{self.model_id} is unreachable: {exc}") from exc
|
|
323
|
+
if response.status_code == 413 or _is_context_rejection(response):
|
|
324
|
+
# The host's own context limit; surfacing it as InputTooLong keeps it a capacity
|
|
325
|
+
# rejection here too, not a 500.
|
|
326
|
+
raise InputTooLong(f"{self.model_id} refused the request, over the maximum context length: "
|
|
327
|
+
f"{response.text[:300]}", question_id=_question_at_fault(response))
|
|
328
|
+
if response.status_code != 200:
|
|
329
|
+
raise self.refusal(response)
|
|
330
|
+
data = response.json()
|
|
331
|
+
raw = {}
|
|
332
|
+
for qid, question in questions.items():
|
|
333
|
+
keys = option_keys(question)
|
|
334
|
+
distribution = answer_distribution(data["answers"][qid], keys)
|
|
335
|
+
# Hosts publish rounded probabilities, zeros included; a zero is a -inf logit
|
|
336
|
+
# downstream, which softmax and the two-stage Choice already handle, so the
|
|
337
|
+
# host's distribution passes through exactly instead of picking up a floor.
|
|
338
|
+
with np.errstate(divide="ignore"):
|
|
339
|
+
raw[qid] = RawScore(keys=keys, logits=np.log(np.array([distribution[k] for k in keys], dtype=np.float64)))
|
|
340
|
+
return Scored(raw=raw, input_tokens=int((data.get("usage") or {}).get("input_tokens") or 0))
|
|
341
|
+
|
|
342
|
+
def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
|
|
343
|
+
scored = self.score(request.state, request.questions)
|
|
344
|
+
answers = {qid: answer(request.questions[qid], scored.raw[qid], self.calibration) for qid in request.questions}
|
|
345
|
+
return SystemOneResponse(model=self.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens))
|
|
346
|
+
|
|
347
|
+
def answer(self, question, raw: RawScore):
|
|
348
|
+
return answer(question, raw, self.calibration)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
VLLM_PREFIX = "vllm:"
|
|
352
|
+
VLLM_PASS_OPTIONS = 26
|
|
353
|
+
"""vLLM labels a choice's options A to Z in the prompt, so it reads at most 26 in one pass; a wider
|
|
354
|
+
choice runs in two stages. Its noul and score limits (two sides, ten levels) are within ruling's."""
|
|
355
|
+
VLLM_MAX_QUESTIONS = MAX_QUESTIONS
|
|
356
|
+
"""vLLM takes as many questions in one request as the hosted API; a wider stage one is split across requests."""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class VllmEngine(TwoStageChoice, TypeSafeEngine):
|
|
360
|
+
"""A model, or a LoRA adapter by name, behind vLLM's own `/v1/systemone`.
|
|
361
|
+
|
|
362
|
+
vLLM reads the first reply token's label logprobs the way ruling does, so
|
|
363
|
+
the same questions come back in the same shape, batched across requests on
|
|
364
|
+
the GPU, and an adapter trained here and exported with `ruling export-adapter`
|
|
365
|
+
answers under the name vLLM was started with. Only the wire differs from the
|
|
366
|
+
TypeSafe engine: a `vllm:` id, no API key by default, and vLLM's refusals,
|
|
367
|
+
which are 400s with a reason, surface as invalid input rather than host
|
|
368
|
+
failures.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
pass_options = VLLM_PASS_OPTIONS
|
|
372
|
+
# Whether vLLM's decision endpoint reads images is unverified; refuse rather than send what it may drop.
|
|
373
|
+
reads_images = False
|
|
374
|
+
prefix = VLLM_PREFIX
|
|
375
|
+
|
|
376
|
+
def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
377
|
+
self._check_width(questions)
|
|
378
|
+
return TwoStageChoice.score(self, state, questions)
|
|
379
|
+
|
|
380
|
+
def _score_plain(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
381
|
+
"""One request per `VLLM_MAX_QUESTIONS` questions: the first stage of a wide choice asks one per option."""
|
|
382
|
+
items = list(questions.items())
|
|
383
|
+
parts = [TypeSafeEngine.score(self, state, dict(items[start:start + VLLM_MAX_QUESTIONS]))
|
|
384
|
+
for start in range(0, len(items), VLLM_MAX_QUESTIONS)]
|
|
385
|
+
return Scored(raw={qid: raw for part in parts for qid, raw in part.raw.items()},
|
|
386
|
+
input_tokens=sum(part.input_tokens for part in parts))
|
|
387
|
+
|
|
388
|
+
def refusal(self, response: httpx.Response) -> RuntimeError:
|
|
389
|
+
if response.status_code == 400:
|
|
390
|
+
return ValueError(f"{self.model_id} refused the request: {response.text[:300]}")
|
|
391
|
+
if response.status_code == 501:
|
|
392
|
+
return RuntimeError(f"{self.model_id} has no read strategy in this vLLM; it cannot answer typed questions: "
|
|
393
|
+
f"{response.text[:300]}")
|
|
394
|
+
return unavailable_or_error(self.model_id, response)
|
ruling/ledger.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Every decision a server answers, and the outcomes reported for it later, kept on disk.
|
|
2
|
+
|
|
3
|
+
An outcome is what turned out to be right for a question, reported by whoever
|
|
4
|
+
learns it: the ticket's final team, whether the refund was granted. Decisions
|
|
5
|
+
with outcomes are training records, so a model can be retrained on its own
|
|
6
|
+
traffic with `ruling export-decisions` and `ruling train`.
|
|
7
|
+
|
|
8
|
+
SQLite keeps the ledger on disk and indexed, so lookups and memory stay flat
|
|
9
|
+
however much traffic it holds. A retention bounds the disk: a decision nobody
|
|
10
|
+
reports an outcome for within it is dropped, state and all, the next time a
|
|
11
|
+
decision is recorded; a decision with an outcome is training data and stays.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Callable, Iterator
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
from ruling.evals import Label, Record
|
|
27
|
+
from ruling.questions import SystemOneRequest, SystemOneResponse
|
|
28
|
+
|
|
29
|
+
SCHEMA = """
|
|
30
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
created REAL NOT NULL,
|
|
33
|
+
model TEXT NOT NULL,
|
|
34
|
+
request TEXT NOT NULL,
|
|
35
|
+
response TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS decisions_created ON decisions(created);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS outcomes (
|
|
39
|
+
decision TEXT NOT NULL REFERENCES decisions(id),
|
|
40
|
+
question TEXT NOT NULL,
|
|
41
|
+
label TEXT NOT NULL,
|
|
42
|
+
reported REAL NOT NULL,
|
|
43
|
+
PRIMARY KEY (decision, question)
|
|
44
|
+
);
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Outcome(BaseModel):
|
|
49
|
+
"""What turned out to be right, per question, in the label forms a training record takes."""
|
|
50
|
+
|
|
51
|
+
labels: dict[str, Label] = Field(min_length=1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UnknownDecision(KeyError):
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
return f"no decision {self.args[0]!r} in the ledger"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DecisionLedger:
|
|
60
|
+
def __init__(self, path: Path, retain_seconds: float | None = None, now: Callable[[], float] = time.time):
|
|
61
|
+
# Server requests arrive on a thread pool; one connection behind a lock serves them all.
|
|
62
|
+
self._db = sqlite3.connect(path, check_same_thread=False)
|
|
63
|
+
self._db.executescript(SCHEMA)
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
self._retain = retain_seconds
|
|
66
|
+
self._now = now
|
|
67
|
+
|
|
68
|
+
def record(self, request: SystemOneRequest, response: SystemOneResponse) -> str:
|
|
69
|
+
"""Store one answered request and return the id outcomes will refer to.
|
|
70
|
+
|
|
71
|
+
With a retention, unresolved decisions older than it go first, so the ledger never
|
|
72
|
+
outgrows the traffic of one retention period plus what has been resolved."""
|
|
73
|
+
decision = uuid.uuid4().hex
|
|
74
|
+
now = self._now()
|
|
75
|
+
with self._lock, self._db:
|
|
76
|
+
if self._retain is not None:
|
|
77
|
+
self._db.execute("DELETE FROM decisions WHERE created < ? AND id NOT IN (SELECT decision FROM outcomes)",
|
|
78
|
+
(now - self._retain,))
|
|
79
|
+
self._db.execute("INSERT INTO decisions VALUES (?, ?, ?, ?, ?)",
|
|
80
|
+
(decision, now, response.model, request.model_dump_json(exclude_none=True),
|
|
81
|
+
response.model_dump_json(exclude_none=True)))
|
|
82
|
+
return decision
|
|
83
|
+
|
|
84
|
+
def ids(self) -> list[str]:
|
|
85
|
+
"""Every decision still held, oldest first."""
|
|
86
|
+
with self._lock:
|
|
87
|
+
return [row[0] for row in self._db.execute("SELECT id FROM decisions ORDER BY created, rowid")]
|
|
88
|
+
|
|
89
|
+
def outcome(self, decision: str, labels: dict) -> Record:
|
|
90
|
+
"""Attach outcomes to a decision; a later outcome for the same question replaces the earlier one.
|
|
91
|
+
|
|
92
|
+
The labels are checked as a training record would check them, so nothing
|
|
93
|
+
stored here can fail later in `ruling train`. Returns the record as it now stands.
|
|
94
|
+
"""
|
|
95
|
+
with self._lock, self._db:
|
|
96
|
+
row = self._db.execute("SELECT request FROM decisions WHERE id = ?", (decision,)).fetchone()
|
|
97
|
+
if row is None:
|
|
98
|
+
raise UnknownDecision(decision)
|
|
99
|
+
record = self._record(decision, json.loads(row[0]), self._labels(decision) | labels)
|
|
100
|
+
self._db.executemany("INSERT OR REPLACE INTO outcomes VALUES (?, ?, ?, ?)",
|
|
101
|
+
[(decision, qid, json.dumps(label), self._now()) for qid, label in labels.items()])
|
|
102
|
+
return record
|
|
103
|
+
|
|
104
|
+
def records(self) -> Iterator[Record]:
|
|
105
|
+
"""Every decision with at least one outcome, oldest first, as a training record."""
|
|
106
|
+
with self._lock:
|
|
107
|
+
rows = self._db.execute(
|
|
108
|
+
"SELECT id, request FROM decisions WHERE id IN (SELECT decision FROM outcomes) ORDER BY created"
|
|
109
|
+
).fetchall()
|
|
110
|
+
labeled = [(decision, json.loads(request), self._labels(decision)) for decision, request in rows]
|
|
111
|
+
for decision, request, labels in labeled:
|
|
112
|
+
yield self._record(decision, request, labels)
|
|
113
|
+
|
|
114
|
+
def _labels(self, decision: str) -> dict:
|
|
115
|
+
return {qid: json.loads(label) for qid, label in
|
|
116
|
+
self._db.execute("SELECT question, label FROM outcomes WHERE decision = ?", (decision,))}
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _record(decision: str, request: dict, labels: dict) -> Record:
|
|
120
|
+
return Record(id=decision, state=request["state"], questions=request["questions"], labels=labels)
|
ruling/metrics.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Decision metrics in the form hosted decision platforms report, so a ruling report reads like theirs.
|
|
2
|
+
|
|
3
|
+
Inputs are parallel lists of probability vectors and target vectors over the
|
|
4
|
+
same keys; a target may be one-hot or a soft distribution, whose most likely
|
|
5
|
+
option counts as the correct answer for accuracy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
THRESHOLDS = (0.5, 0.7, 0.9, 0.95)
|
|
13
|
+
"""Top-1 confidence thresholds for selective accuracy. Reported under their string form; the names never change."""
|
|
14
|
+
|
|
15
|
+
PROBABILITY_FLOOR = 1e-12
|
|
16
|
+
"""Log loss is taken on probabilities floored here, so an impossible answer costs a finite amount."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decision_metrics(probs: list[np.ndarray], targets: list[np.ndarray], bins: int = 10) -> dict:
|
|
20
|
+
"""Pooled metrics over paired distributions and targets; `{"n": 0}` for empty input.
|
|
21
|
+
|
|
22
|
+
- accuracy: argmax(p) equals argmax(y)
|
|
23
|
+
- nll: −Σ y log p, averaged
|
|
24
|
+
- brier: Σ (p − y)², averaged
|
|
25
|
+
- ece: expected calibration error over `bins` equal-width bins on top-1 confidence
|
|
26
|
+
- mean_confidence: average top-1 probability
|
|
27
|
+
- selective: coverage and accuracy among decisions whose top-1 confidence is at least each threshold
|
|
28
|
+
"""
|
|
29
|
+
if len(probs) != len(targets):
|
|
30
|
+
raise ValueError("probs and targets must have the same length")
|
|
31
|
+
n = len(probs)
|
|
32
|
+
if n == 0:
|
|
33
|
+
return {"n": 0}
|
|
34
|
+
confidence, correct, nll, brier = (np.empty(n) for _ in range(4))
|
|
35
|
+
for i, (p, y) in enumerate(zip(probs, targets)):
|
|
36
|
+
p, y = np.asarray(p, dtype=np.float64), np.asarray(y, dtype=np.float64)
|
|
37
|
+
if p.shape != y.shape:
|
|
38
|
+
raise ValueError(f"decision {i}: probabilities have {p.size} entries, target has {y.size}")
|
|
39
|
+
predicted = int(p.argmax())
|
|
40
|
+
confidence[i] = p[predicted]
|
|
41
|
+
correct[i] = predicted == int(y.argmax())
|
|
42
|
+
nll[i] = -(y * np.log(np.maximum(p, PROBABILITY_FLOOR))).sum()
|
|
43
|
+
brier[i] = ((p - y) ** 2).sum()
|
|
44
|
+
ece = 0.0
|
|
45
|
+
for b in range(bins):
|
|
46
|
+
lo, hi = b / bins, (b + 1) / bins
|
|
47
|
+
# A confidence of exactly zero belongs to the first bin rather than to none.
|
|
48
|
+
member = ((lo < confidence) & (confidence <= hi)) | ((b == 0) & (confidence == 0))
|
|
49
|
+
if member.any():
|
|
50
|
+
ece += member.mean() * abs(confidence[member].mean() - correct[member].mean())
|
|
51
|
+
selective = {}
|
|
52
|
+
for threshold in THRESHOLDS:
|
|
53
|
+
covered = confidence >= threshold
|
|
54
|
+
selective[str(threshold)] = {
|
|
55
|
+
"coverage": float(covered.mean()),
|
|
56
|
+
"accuracy": float(correct[covered].mean()) if covered.any() else None,
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
"n": n,
|
|
60
|
+
"accuracy": float(correct.mean()),
|
|
61
|
+
"nll": float(nll.mean()),
|
|
62
|
+
"brier": float(brier.mean()),
|
|
63
|
+
"ece": float(ece),
|
|
64
|
+
"mean_confidence": float(confidence.mean()),
|
|
65
|
+
"selective": selective,
|
|
66
|
+
}
|