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/questions.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Wire types for the System One API: questions in, typed answers out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
from typing import Annotated, Any, Literal, Union
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
State = Union[str, dict[str, Any], list[Any]]
|
|
13
|
+
|
|
14
|
+
# Instructions, option descriptions, and level descriptions may be plain text or
|
|
15
|
+
# JSON structure; structure is rendered compactly into the prompt.
|
|
16
|
+
Text = Union[str, dict[str, Any], list[Any]]
|
|
17
|
+
|
|
18
|
+
# The hosted services' request limits, so a client written against them never
|
|
19
|
+
# sees a different answer here. Above the tokenizer's single-token codes a
|
|
20
|
+
# Choice is answered in two stages (see Engine).
|
|
21
|
+
MAX_CHOICE_OPTIONS = 255
|
|
22
|
+
MAX_SCORE_LEVELS = 10
|
|
23
|
+
MAX_QUESTIONS = 64
|
|
24
|
+
|
|
25
|
+
# An image is {"type": "image", "media_type", "data": <base64>} anywhere in the state,
|
|
26
|
+
# PNG, JPEG or WebP, at most 8 per decision and 5 MB each.
|
|
27
|
+
MAX_IMAGES = 8
|
|
28
|
+
MAX_IMAGE_BYTES = 5 * 2**20
|
|
29
|
+
IMAGE_REFERENCE = "<image {}>"
|
|
30
|
+
|
|
31
|
+
# The hosted SDK sends its default model name on every request. Treating these as
|
|
32
|
+
# "whichever model this server loaded" is what lets that client talk to ruling
|
|
33
|
+
# unchanged; the response still reports the model that actually answered.
|
|
34
|
+
DEFAULT_MODEL_ALIASES = ("jev-latest", "default")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_text(value: Text) -> str:
|
|
38
|
+
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def has_text(value: Text | None) -> bool:
|
|
42
|
+
return value is not None and bool(render_text(value).strip())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Strict(BaseModel):
|
|
46
|
+
"""A question refuses fields it does not know, as the hosted services do, so a typo
|
|
47
|
+
such as `instruction` fails loudly instead of asking a different question."""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Choice(Strict):
|
|
53
|
+
"""Pick one option from a closed set. Criteria maps option key to an optional description.
|
|
54
|
+
|
|
55
|
+
The schema admits one option, as the hosted services' does; `check_askable` is what
|
|
56
|
+
refuses it, naming the question.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
type: Literal["choice"] = "choice"
|
|
60
|
+
instructions: Text | None = None
|
|
61
|
+
criteria: dict[str, Text | None] = Field(min_length=1, max_length=MAX_CHOICE_OPTIONS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Score(Strict):
|
|
65
|
+
"""Place the state on an ordered scale. Criteria lists the levels from lowest to highest."""
|
|
66
|
+
|
|
67
|
+
type: Literal["score"] = "score"
|
|
68
|
+
instructions: Text | None = None
|
|
69
|
+
criteria: list[Text] = Field(min_length=2, max_length=MAX_SCORE_LEVELS)
|
|
70
|
+
|
|
71
|
+
@field_validator("criteria")
|
|
72
|
+
@classmethod
|
|
73
|
+
def levels_are_distinct(cls, levels: list[Text]) -> list[Text]:
|
|
74
|
+
rendered = [render_text(level) for level in levels]
|
|
75
|
+
if len(set(rendered)) != len(rendered):
|
|
76
|
+
raise ValueError("score levels must be distinct")
|
|
77
|
+
return levels
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NoulCriteria(Strict):
|
|
81
|
+
"""Optional descriptions of what a true and a false answer mean."""
|
|
82
|
+
|
|
83
|
+
true: Text | None = None
|
|
84
|
+
false: Text | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Noul(Strict):
|
|
88
|
+
"""Judge whether a statement about the state is true. The statement is the instructions,
|
|
89
|
+
or, without them, what the criteria say a true or a false answer means."""
|
|
90
|
+
|
|
91
|
+
type: Literal["noul"] = "noul"
|
|
92
|
+
instructions: Text | None = None
|
|
93
|
+
criteria: NoulCriteria | None = None
|
|
94
|
+
|
|
95
|
+
def described(self) -> bool:
|
|
96
|
+
return self.criteria is not None and (has_text(self.criteria.true) or has_text(self.criteria.false))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
Question = Annotated[Union[Choice, Score, Noul], Field(discriminator="type")]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# What a noul asks when it has no instructions and only its criteria describe the answers.
|
|
103
|
+
DESCRIBED_NOUL = "Which description fits the state?"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def question_statement(question: Question) -> str:
|
|
107
|
+
"""The question's instructions as prompt text, or the standing question a described noul asks."""
|
|
108
|
+
return render_text(question.instructions) if has_text(question.instructions) else DESCRIBED_NOUL
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Image(Strict):
|
|
112
|
+
type: Literal["image"]
|
|
113
|
+
media_type: Literal["image/png", "image/jpeg", "image/webp"]
|
|
114
|
+
data: str
|
|
115
|
+
|
|
116
|
+
@field_validator("data")
|
|
117
|
+
@classmethod
|
|
118
|
+
def decodes_within_the_limit(cls, data: str) -> str:
|
|
119
|
+
try:
|
|
120
|
+
size = len(base64.b64decode(data, validate=True))
|
|
121
|
+
except binascii.Error as exc:
|
|
122
|
+
raise ValueError(f"image data must be base64: {exc}") from exc
|
|
123
|
+
if size > MAX_IMAGE_BYTES:
|
|
124
|
+
raise ValueError(f"an image may be at most {MAX_IMAGE_BYTES // 2**20} MB, got {size / 2**20:.1f} MB")
|
|
125
|
+
return data
|
|
126
|
+
|
|
127
|
+
def data_url(self) -> str:
|
|
128
|
+
return f"data:{self.media_type};base64,{self.data}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_image(value: Any) -> bool:
|
|
132
|
+
return isinstance(value, dict) and value.get("type") == "image"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def state_images(state: State) -> list[Image]:
|
|
136
|
+
"""Every image in the state, depth first in document order, each validated."""
|
|
137
|
+
if _is_image(state):
|
|
138
|
+
return [Image.model_validate(state)]
|
|
139
|
+
children = state.values() if isinstance(state, dict) else state if isinstance(state, list) else ()
|
|
140
|
+
return [image for child in children for image in state_images(child)]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def without_images(state: State) -> State:
|
|
144
|
+
"""The state with each image replaced by its reference, `<image 1>` and on, in `state_images` order."""
|
|
145
|
+
counter = iter(range(1, MAX_IMAGES + 1))
|
|
146
|
+
|
|
147
|
+
def replace(value: Any) -> Any:
|
|
148
|
+
if _is_image(value):
|
|
149
|
+
return IMAGE_REFERENCE.format(next(counter))
|
|
150
|
+
if isinstance(value, dict):
|
|
151
|
+
return {key: replace(child) for key, child in value.items()}
|
|
152
|
+
if isinstance(value, list):
|
|
153
|
+
return [replace(child) for child in value]
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
return replace(state)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class UnsupportedModality(ValueError):
|
|
160
|
+
"""A state holds images and the model that would answer reads only text."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def check_readable(state: State, reads_images: bool) -> None:
|
|
164
|
+
if not reads_images and state_images(state):
|
|
165
|
+
raise UnsupportedModality("this model reads only text; the state holds images")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class InvalidOptions(ValueError):
|
|
169
|
+
"""A well-formed question the model cannot answer, named so the error can point at it."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, question_id: str, message: str):
|
|
172
|
+
super().__init__(f"questions.{question_id}: {message}")
|
|
173
|
+
self.question_id = question_id
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def check_askable(questions: dict[str, Question], max_options: int) -> None:
|
|
177
|
+
"""Refuse what the hosted services refuse after schema validation: a choice outside
|
|
178
|
+
2..`max_options`, or a question with nothing to ask."""
|
|
179
|
+
for qid, question in questions.items():
|
|
180
|
+
if isinstance(question, Choice) and not 2 <= len(question.criteria) <= max_options:
|
|
181
|
+
raise InvalidOptions(qid, f"a choice needs 2 to {max_options} options, got {len(question.criteria)}")
|
|
182
|
+
if not has_text(question.instructions) and not (isinstance(question, Noul) and question.described()):
|
|
183
|
+
raise InvalidOptions(qid, "needs instructions (a yes/no question may describe true or false instead)")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SystemOneRequest(BaseModel):
|
|
187
|
+
model: str | None = None
|
|
188
|
+
state: State
|
|
189
|
+
questions: dict[str, Question] = Field(min_length=1, max_length=MAX_QUESTIONS)
|
|
190
|
+
# Marks traffic that must never become training data, such as benchmarks and evaluations.
|
|
191
|
+
exclude_from_training: bool = False
|
|
192
|
+
|
|
193
|
+
@field_validator("state")
|
|
194
|
+
@classmethod
|
|
195
|
+
def images_are_valid(cls, state: State) -> State:
|
|
196
|
+
return check_images(state)
|
|
197
|
+
|
|
198
|
+
@model_validator(mode="after")
|
|
199
|
+
def ids_are_non_empty(self) -> "SystemOneRequest":
|
|
200
|
+
if any(not qid for qid in self.questions):
|
|
201
|
+
raise ValueError("question ids must be non-empty")
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_images(state: State) -> State:
|
|
206
|
+
"""Validate every image in the state and their count; returns the state unchanged."""
|
|
207
|
+
images = state_images(state)
|
|
208
|
+
if len(images) > MAX_IMAGES:
|
|
209
|
+
raise ValueError(f"a state may hold at most {MAX_IMAGES} images, got {len(images)}")
|
|
210
|
+
return state
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ChoiceAnswer(BaseModel):
|
|
214
|
+
type: Literal["choice"] = "choice"
|
|
215
|
+
choice: str
|
|
216
|
+
probabilities: dict[str, float]
|
|
217
|
+
confidence: float
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ScoreAnswer(BaseModel):
|
|
221
|
+
type: Literal["score"] = "score"
|
|
222
|
+
score: float
|
|
223
|
+
legend: dict[str, str]
|
|
224
|
+
probabilities: dict[str, float]
|
|
225
|
+
confidence: float
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class NoulAnswer(BaseModel):
|
|
229
|
+
type: Literal["noul"] = "noul"
|
|
230
|
+
noul: float
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
Answer = Annotated[Union[ChoiceAnswer, ScoreAnswer, NoulAnswer], Field(discriminator="type")]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class Usage(BaseModel):
|
|
237
|
+
input_tokens: int
|
|
238
|
+
output_tokens: int = 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class SystemOneResponse(BaseModel):
|
|
242
|
+
id: str | None = None
|
|
243
|
+
"""The decision's id in the server's ledger, for reporting its outcome; absent when nothing is logged."""
|
|
244
|
+
model: str
|
|
245
|
+
answers: dict[str, Answer]
|
|
246
|
+
usage: Usage
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class ModelInfo(BaseModel):
|
|
250
|
+
"""One served model. `name`, `description` and `release_date` are what the hosted
|
|
251
|
+
API reports and what its SDK decodes; the rest is detail only a local engine has.
|
|
252
|
+
`name` is what a request sends as `model`; `base` is the weights behind it."""
|
|
253
|
+
|
|
254
|
+
name: str
|
|
255
|
+
description: str
|
|
256
|
+
release_date: str
|
|
257
|
+
id: str
|
|
258
|
+
base: str
|
|
259
|
+
revision: str | None
|
|
260
|
+
adapter: str | None
|
|
261
|
+
calibration: dict[str, float]
|
|
262
|
+
max_input_tokens: int
|
|
263
|
+
max_branch_tokens: int
|
|
264
|
+
max_options: int
|
|
265
|
+
rotations: int
|
|
266
|
+
prior_debias: bool
|
|
267
|
+
modalities: list[Literal["text", "image"]]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ModelList(BaseModel):
|
|
271
|
+
models: list[ModelInfo]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def option_keys(question: Choice | Score | Noul) -> list[str]:
|
|
275
|
+
"""The ordered answer set a question is scored over."""
|
|
276
|
+
if isinstance(question, Choice):
|
|
277
|
+
return list(question.criteria)
|
|
278
|
+
if isinstance(question, Score):
|
|
279
|
+
return [str(i) for i in range(len(question.criteria))]
|
|
280
|
+
return ["yes", "no"]
|
ruling/registry.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Which models one server answers for: the environment's primary, plus named entries from a file.
|
|
2
|
+
|
|
3
|
+
Entries that fine-tune the same base share its loaded weights: the LoRA layers
|
|
4
|
+
are swapped under the engine lock before each request, so a server can hold a
|
|
5
|
+
plain base, a tuned adapter per decision, and a large escalation model at the
|
|
6
|
+
cost of one copy of each base.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ruling.calibration import Calibration
|
|
16
|
+
from ruling.cascade import CascadeEngine
|
|
17
|
+
from ruling.config import Settings
|
|
18
|
+
from ruling.decision import DecisionEngine, is_checkpoint
|
|
19
|
+
from ruling.engine import Engine, model_released
|
|
20
|
+
from ruling.failover import FailoverEngine
|
|
21
|
+
from ruling.hosted import (
|
|
22
|
+
HOSTED_PREFIX,
|
|
23
|
+
OPENROUTER_PREFIX,
|
|
24
|
+
TYPESAFE_PREFIX,
|
|
25
|
+
VLLM_PREFIX,
|
|
26
|
+
Budget,
|
|
27
|
+
HostedEngine,
|
|
28
|
+
OpenRouterEngine,
|
|
29
|
+
TypeSafeEngine,
|
|
30
|
+
VllmEngine,
|
|
31
|
+
)
|
|
32
|
+
from ruling.questions import DEFAULT_MODEL_ALIASES, ModelInfo
|
|
33
|
+
from ruling.vision import VISION_PREFIX
|
|
34
|
+
|
|
35
|
+
ENTRY_FIELDS = ("model", "adapter", "calibration", "base_url")
|
|
36
|
+
REMOTE_PREFIXES = (TYPESAFE_PREFIX, VLLM_PREFIX, HOSTED_PREFIX)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnknownModel(KeyError):
|
|
40
|
+
def __init__(self, name: str, known: list[str]):
|
|
41
|
+
super().__init__(name)
|
|
42
|
+
self.name = name
|
|
43
|
+
self.known = known
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return f"no model {self.name!r}; this server answers for {', '.join(repr(k) for k in self.known)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Entry:
|
|
51
|
+
model: str
|
|
52
|
+
adapter: Path | None = None
|
|
53
|
+
calibration: Path | None = None
|
|
54
|
+
base_url: str | None = None
|
|
55
|
+
"""Where a `typesafe:`, `vllm:` or `openai:` entry is served, in place of the global setting."""
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def parse(cls, name: str, raw: object, relative_to: Path) -> "Entry":
|
|
59
|
+
if not isinstance(raw, dict) or "model" not in raw:
|
|
60
|
+
raise ValueError(f"model {name!r} must be an object with at least a 'model' field")
|
|
61
|
+
unknown = set(raw) - set(ENTRY_FIELDS)
|
|
62
|
+
if unknown:
|
|
63
|
+
raise ValueError(f"model {name!r} has unknown fields {sorted(unknown)}; allowed: {list(ENTRY_FIELDS)}")
|
|
64
|
+
paths = {key: relative_to / raw[key] for key in ("adapter", "calibration") if raw.get(key)}
|
|
65
|
+
base_url = raw.get("base_url")
|
|
66
|
+
if base_url and not str(raw["model"]).startswith(REMOTE_PREFIXES):
|
|
67
|
+
raise ValueError(f"model {name!r} has a base_url, which only {', '.join(REMOTE_PREFIXES)} models use")
|
|
68
|
+
return cls(model=str(raw["model"]), base_url=base_url, **paths)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_entries(path: Path) -> dict[str, Entry]:
|
|
72
|
+
"""Named entries from a JSON object; relative paths resolve against the file's directory."""
|
|
73
|
+
data = json.loads(path.read_text())
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise ValueError(f"{path} must hold a JSON object of model name to entry")
|
|
76
|
+
entries = {}
|
|
77
|
+
for name, raw in data.items():
|
|
78
|
+
if not name or name in DEFAULT_MODEL_ALIASES:
|
|
79
|
+
raise ValueError(f"{name!r} cannot name a model; it is empty or a default alias")
|
|
80
|
+
entries[name] = Entry.parse(name, raw, path.parent)
|
|
81
|
+
return entries
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_engine(settings: Settings):
|
|
85
|
+
"""Any OpenAI-compatible host, OpenRouter, a trained decision checkpoint, or a local decoder.
|
|
86
|
+
|
|
87
|
+
With `cascade_to` set, that model answers whichever questions the first one is
|
|
88
|
+
unsure about, judged against `cascade_threshold`. With `failover_to` set, that
|
|
89
|
+
model answers whenever the first one is unavailable.
|
|
90
|
+
"""
|
|
91
|
+
if settings.failover_to:
|
|
92
|
+
primary = build_engine(replace(settings, failover_to=None))
|
|
93
|
+
fallback = build_engine(replace(settings, model=settings.failover_to, failover_to=None, cascade_to=None,
|
|
94
|
+
adapter_path=None, calibration_path=None))
|
|
95
|
+
return FailoverEngine(primary, fallback)
|
|
96
|
+
if settings.cascade_to:
|
|
97
|
+
if settings.cascade_threshold is None:
|
|
98
|
+
raise ValueError("set RULING_CASCADE_THRESHOLD, the top probability below which a question is escalated")
|
|
99
|
+
first = build_engine(replace(settings, cascade_to=None))
|
|
100
|
+
second = build_engine(replace(settings, model=settings.cascade_to, cascade_to=None, adapter_path=None,
|
|
101
|
+
calibration_path=None))
|
|
102
|
+
return CascadeEngine(first, second, settings.cascade_threshold)
|
|
103
|
+
calibration = Calibration.load(settings.calibration_path) if settings.calibration_path else Calibration()
|
|
104
|
+
if settings.model.startswith(TYPESAFE_PREFIX):
|
|
105
|
+
return TypeSafeEngine(settings.model, calibration, settings.typesafe_base_url, settings.typesafe_api_key,
|
|
106
|
+
settings.max_input_tokens, settings.max_branch_tokens)
|
|
107
|
+
if settings.model.startswith(VLLM_PREFIX):
|
|
108
|
+
if not settings.vllm_base_url:
|
|
109
|
+
raise ValueError("set RULING_VLLM_BASE_URL to the vLLM server, without a path")
|
|
110
|
+
return VllmEngine(settings.model, calibration, settings.vllm_base_url, settings.vllm_api_key,
|
|
111
|
+
settings.max_input_tokens, settings.max_branch_tokens)
|
|
112
|
+
if settings.model.startswith(HOSTED_PREFIX):
|
|
113
|
+
if not settings.openai_base_url:
|
|
114
|
+
raise ValueError("set RULING_OPENAI_BASE_URL to the endpoint serving the model, ending in /v1")
|
|
115
|
+
return HostedEngine(settings.model, calibration, settings.rotations, settings.openai_base_url,
|
|
116
|
+
settings.openai_api_key, concurrency=settings.openai_concurrency,
|
|
117
|
+
top_logprobs=settings.openai_top_logprobs,
|
|
118
|
+
extra_body=settings.openai_extra_body)
|
|
119
|
+
if settings.model.startswith(OPENROUTER_PREFIX):
|
|
120
|
+
if not settings.openrouter_providers:
|
|
121
|
+
raise ValueError("set RULING_OPENROUTER_PROVIDERS to the hosts allowed to serve the model")
|
|
122
|
+
return OpenRouterEngine(settings.model, calibration, settings.rotations, list(settings.openrouter_providers),
|
|
123
|
+
Budget(settings.openrouter_budget_usd))
|
|
124
|
+
if settings.model.startswith(VISION_PREFIX):
|
|
125
|
+
from ruling.vision import VisionEngine
|
|
126
|
+
vision = VisionEngine(settings.model, Calibration() if settings.adapter_path else calibration,
|
|
127
|
+
settings.max_input_tokens, settings.rotations, settings.prior_debias,
|
|
128
|
+
settings.max_image_tokens, settings.max_branch_tokens)
|
|
129
|
+
# The adapter goes onto the loaded language half, as for any variant sharing a base.
|
|
130
|
+
return vision.variant(settings.adapter_path, calibration) if settings.adapter_path else vision
|
|
131
|
+
if is_checkpoint(settings.model):
|
|
132
|
+
return DecisionEngine(Path(settings.model), calibration, settings.max_input_tokens)
|
|
133
|
+
return Engine(settings.model, calibration, settings.max_input_tokens, settings.rotations, settings.prior_debias,
|
|
134
|
+
settings.adapter_path, settings.max_branch_tokens)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Registry:
|
|
138
|
+
"""The engines a server answers for, by the name a request sends as `model`."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, default: str, engines: dict[str, object]):
|
|
141
|
+
if default not in engines:
|
|
142
|
+
raise ValueError(f"default model {default!r} is not among {sorted(engines)}")
|
|
143
|
+
self.default = default
|
|
144
|
+
self.engines = engines
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def single(cls, engine) -> "Registry":
|
|
148
|
+
return cls(engine.model_id, {engine.model_id: engine})
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_settings(cls, settings: Settings) -> "Registry":
|
|
152
|
+
primary = build_engine(settings)
|
|
153
|
+
engines: dict[str, object] = {primary.model_id: primary}
|
|
154
|
+
if settings.models_path:
|
|
155
|
+
for name, entry in load_entries(settings.models_path).items():
|
|
156
|
+
if name in engines:
|
|
157
|
+
raise ValueError(f"model {name!r} is both the primary model and an entry in {settings.models_path}")
|
|
158
|
+
engines[name] = _build_entry(entry, settings, engines)
|
|
159
|
+
return cls(primary.model_id, engines)
|
|
160
|
+
|
|
161
|
+
def resolve(self, name: str | None) -> tuple[str, object]:
|
|
162
|
+
"""The entry name and engine a request's `model` field selects; None and the aliases mean the default."""
|
|
163
|
+
if name is None or name in DEFAULT_MODEL_ALIASES:
|
|
164
|
+
name = self.default
|
|
165
|
+
try:
|
|
166
|
+
return name, self.engines[name]
|
|
167
|
+
except KeyError:
|
|
168
|
+
raise UnknownModel(name, sorted(self.engines)) from None
|
|
169
|
+
|
|
170
|
+
def info(self) -> list[ModelInfo]:
|
|
171
|
+
return [ModelInfo(
|
|
172
|
+
name=name, id=name, base=engine.model_id,
|
|
173
|
+
description=f"{engine.model_id} answered by ruling over {engine.rotations} answer orderings",
|
|
174
|
+
release_date=model_released(engine.model_id),
|
|
175
|
+
revision=engine.revision, adapter=engine.adapter, calibration=dict(engine.calibration.temperatures),
|
|
176
|
+
max_input_tokens=engine.max_input_tokens, max_branch_tokens=engine.max_branch_tokens,
|
|
177
|
+
max_options=engine.max_options, rotations=engine.rotations, prior_debias=engine.prior_debias,
|
|
178
|
+
modalities=["text", "image"] if engine.reads_images else ["text"],
|
|
179
|
+
) for name, engine in self.engines.items()]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_entry(entry: Entry, settings: Settings, loaded: dict[str, object]):
|
|
183
|
+
"""An engine for one entry, sharing an already loaded copy of its base when the LoRA shapes allow."""
|
|
184
|
+
calibration = Calibration.load(entry.calibration) if entry.calibration else Calibration()
|
|
185
|
+
for engine in loaded.values():
|
|
186
|
+
if isinstance(engine, Engine) and engine.model_id == entry.model:
|
|
187
|
+
try:
|
|
188
|
+
return engine.variant(entry.adapter, calibration)
|
|
189
|
+
except Engine.IncompatibleAdapter:
|
|
190
|
+
break
|
|
191
|
+
# Only the engine the model's prefix selects reads its address, so setting all three is exact.
|
|
192
|
+
address = ({"typesafe_base_url": entry.base_url, "vllm_base_url": entry.base_url, "openai_base_url": entry.base_url}
|
|
193
|
+
if entry.base_url else {})
|
|
194
|
+
return build_engine(replace(settings, model=entry.model, adapter_path=entry.adapter,
|
|
195
|
+
calibration_path=entry.calibration, cascade_to=None, failover_to=None, **address))
|
ruling/retrain.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Learn from your own traffic: train on the decisions whose outcomes came back, and serve the result
|
|
2
|
+
only when it is better.
|
|
3
|
+
|
|
4
|
+
An adapters directory holds one version per run, `v1`, `v2`, …, and `current`,
|
|
5
|
+
a relative link to the version being served (`RULING_ADAPTER=<dir>/current`).
|
|
6
|
+
A run exports the ledger's resolved decisions, drops any whose state is in a
|
|
7
|
+
held-out set, trains the next version (from `current`'s weights when there is
|
|
8
|
+
one), scores both versions on the held-out sets, and repoints `current` only
|
|
9
|
+
when the new one has lower log loss without losing accuracy. Every run leaves
|
|
10
|
+
its records, its training log and the comparison in its version directory,
|
|
11
|
+
promoted or not.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import mlx.core as mx
|
|
23
|
+
|
|
24
|
+
from ruling.calibration import Calibration
|
|
25
|
+
from ruling.config import Settings
|
|
26
|
+
from ruling.engine import ADAPTER_CONFIG
|
|
27
|
+
from ruling.evals import Record, collect, decision_blocks, load_dataset
|
|
28
|
+
from ruling.ledger import DecisionLedger
|
|
29
|
+
from ruling.registry import build_engine
|
|
30
|
+
from ruling.train import Trainer, TrainConfig, _state_key
|
|
31
|
+
|
|
32
|
+
CURRENT = "current"
|
|
33
|
+
REPORT = "retrain.json"
|
|
34
|
+
DECISIONS = "decisions.jsonl"
|
|
35
|
+
VERSION = re.compile(r"v(\d+)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def should_promote(current: dict, candidate: dict) -> tuple[bool, str]:
|
|
39
|
+
"""Held-out log loss must fall and accuracy must not: a sharper but less often right adapter is not better."""
|
|
40
|
+
if candidate["nll"] >= current["nll"]:
|
|
41
|
+
return False, f"held-out log loss {candidate['nll']:.4f} is not below {current['nll']:.4f}"
|
|
42
|
+
if candidate["accuracy"] < current["accuracy"]:
|
|
43
|
+
return False, f"held-out accuracy {candidate['accuracy']:.4f} fell below {current['accuracy']:.4f}"
|
|
44
|
+
return True, (f"held-out log loss {current['nll']:.4f} -> {candidate['nll']:.4f}, "
|
|
45
|
+
f"accuracy {current['accuracy']:.4f} -> {candidate['accuracy']:.4f}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def next_version(adapters: Path) -> Path:
|
|
49
|
+
numbers = [int(m.group(1)) for p in adapters.iterdir() if (m := VERSION.fullmatch(p.name))] if adapters.exists() else []
|
|
50
|
+
return adapters / f"v{max(numbers, default=0) + 1}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def promote(adapters: Path, version: Path) -> None:
|
|
54
|
+
"""Point `current` at `version` in one rename, so a server starting meanwhile sees the old or the new, never neither."""
|
|
55
|
+
staged = adapters / f".{CURRENT}.next"
|
|
56
|
+
staged.unlink(missing_ok=True)
|
|
57
|
+
staged.symlink_to(version.name)
|
|
58
|
+
os.replace(staged, adapters / CURRENT)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def trainable(records: list[Record], held_out: list[Record]) -> tuple[list[Record], int]:
|
|
62
|
+
"""The records whose states appear in no held-out set, and how many were dropped for appearing in one."""
|
|
63
|
+
held = {_state_key(r.state) for r in held_out}
|
|
64
|
+
kept = [r for r in records if _state_key(r.state) not in held]
|
|
65
|
+
return kept, len(records) - len(kept)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def retrain(ledger: DecisionLedger, adapters: Path, model: str, holdout: list[Path], settings: Settings,
|
|
69
|
+
min_records: int, train_options: dict, log=print) -> dict | None:
|
|
70
|
+
"""One retraining run; returns its report, or None when too few decisions have outcomes yet."""
|
|
71
|
+
held_out = [r for path in holdout for r in load_dataset(path)]
|
|
72
|
+
records, dropped = trainable(list(ledger.records()), held_out)
|
|
73
|
+
if dropped:
|
|
74
|
+
log(f"dropped {dropped} decisions whose states are in the held-out sets")
|
|
75
|
+
if len(records) < min_records:
|
|
76
|
+
log(f"{len(records)} decisions have outcomes; waiting for {min_records}")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
current = adapters / CURRENT
|
|
80
|
+
start = current.resolve() if current.exists() else None
|
|
81
|
+
version = next_version(adapters)
|
|
82
|
+
version.mkdir(parents=True)
|
|
83
|
+
data = version / DECISIONS
|
|
84
|
+
data.write_text("".join(r.model_dump_json(exclude_none=True) + "\n" for r in records))
|
|
85
|
+
Trainer(TrainConfig(model=model, data=[data], output=version, holdout=holdout, from_adapter=start,
|
|
86
|
+
max_image_tokens=settings.max_image_tokens, **_lora_layout(start), **train_options)).run(log)
|
|
87
|
+
# The trainer's copy of the model is unreachable now; return its memory before loading the scorer's.
|
|
88
|
+
mx.clear_cache()
|
|
89
|
+
|
|
90
|
+
served = build_engine(replace(settings, model=model, adapter_path=start, calibration_path=None, cascade_to=None,
|
|
91
|
+
failover_to=None))
|
|
92
|
+
candidate = served.variant(version, Calibration())
|
|
93
|
+
scores = {name: decision_blocks(collect(engine, held_out), Calibration())["overall"]
|
|
94
|
+
for name, engine in (("current", served), ("candidate", candidate))}
|
|
95
|
+
promoted, reason = should_promote(scores["current"], scores["candidate"])
|
|
96
|
+
report = {"version": version.name, "started_from": start.name if start else None, "records": len(records),
|
|
97
|
+
"dropped_held_out": dropped, "holdout": [str(p) for p in holdout], **scores,
|
|
98
|
+
"promoted": promoted, "reason": reason}
|
|
99
|
+
(version / REPORT).write_text(json.dumps(report, indent=2) + "\n")
|
|
100
|
+
if promoted:
|
|
101
|
+
promote(adapters, version)
|
|
102
|
+
log(f"{version.name}: {'promoted' if promoted else 'kept ' + (start.name if start else 'the base model')}; {reason}")
|
|
103
|
+
return report
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def history(adapters: Path) -> dict:
|
|
107
|
+
"""Every finished run's report in version order, and the version `current` points at.
|
|
108
|
+
|
|
109
|
+
A version still training, or one whose run failed, has no report and is left out."""
|
|
110
|
+
link = adapters / CURRENT
|
|
111
|
+
reports = [json.loads((version / REPORT).read_text())
|
|
112
|
+
for version in sorted((p for p in adapters.iterdir() if VERSION.fullmatch(p.name)),
|
|
113
|
+
key=lambda p: int(p.name[1:]))
|
|
114
|
+
if (version / REPORT).exists()] if adapters.exists() else []
|
|
115
|
+
return {"current": link.resolve().name if link.exists() else None, "versions": reports}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _lora_layout(adapter: Path | None) -> dict:
|
|
119
|
+
"""The LoRA settings to train with: the starting adapter's, which its weights only fit, else the trainer's defaults."""
|
|
120
|
+
if adapter is None:
|
|
121
|
+
return {}
|
|
122
|
+
saved = json.loads((adapter / ADAPTER_CONFIG).read_text())
|
|
123
|
+
lora = saved["lora_parameters"]
|
|
124
|
+
return {"lora_layers": saved["num_layers"], "lora_rank": lora["rank"], "lora_scale": lora["scale"],
|
|
125
|
+
"lora_dropout": lora["dropout"]}
|
ruling/sdk.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Python client mirroring the shape of the hosted System One SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ruling.questions import Choice, ModelInfo, ModelList, Noul, Score, State, SystemOneRequest, SystemOneResponse
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8010"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RulingClient:
|
|
15
|
+
def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 60.0):
|
|
16
|
+
self.base_url = base_url or os.environ.get("RULING_BASE_URL", DEFAULT_BASE_URL)
|
|
17
|
+
# The server's own RULING_API_KEY, so one environment configures both ends.
|
|
18
|
+
api_key = api_key or os.environ.get("RULING_API_KEY")
|
|
19
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
20
|
+
self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
21
|
+
|
|
22
|
+
def system_one(
|
|
23
|
+
self, state: State, questions: dict[str, Choice | Score | Noul], model: str | None = None,
|
|
24
|
+
exclude_from_training: bool = False,
|
|
25
|
+
) -> SystemOneResponse:
|
|
26
|
+
request = SystemOneRequest(model=model, state=state, questions=questions,
|
|
27
|
+
exclude_from_training=exclude_from_training)
|
|
28
|
+
# The flag goes only when set, so every other body is exactly System One's.
|
|
29
|
+
body = request.model_dump(exclude=None if exclude_from_training else {"exclude_from_training"})
|
|
30
|
+
response = self._http.post("/v1/systemone", json=body)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
return SystemOneResponse.model_validate(response.json())
|
|
33
|
+
|
|
34
|
+
def outcome(self, decision_id: str, labels: dict) -> dict:
|
|
35
|
+
"""Report what turned out to be right for a logged decision; returns the labels as stored."""
|
|
36
|
+
response = self._http.post(f"/v1/decisions/{decision_id}/outcome", json={"labels": labels})
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
return response.json()["labels"]
|
|
39
|
+
|
|
40
|
+
def models(self) -> list[ModelInfo]:
|
|
41
|
+
response = self._http.get("/v1/models")
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return ModelList.model_validate(response.json()).models
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
self._http.close()
|
|
47
|
+
|
|
48
|
+
def __enter__(self) -> "RulingClient":
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *exc) -> None:
|
|
52
|
+
self.close()
|