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/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=&lt;adapters&gt;/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>