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/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
+ }