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/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()