ruling 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → ruling-0.3.0}/PKG-INFO +9 -6
- {ruling-0.2.0 → ruling-0.3.0}/README.md +6 -5
- {ruling-0.2.0 → ruling-0.3.0}/pyproject.toml +4 -1
- {ruling-0.2.0 → ruling-0.3.0}/ruling/__init__.py +4 -1
- {ruling-0.2.0 → ruling-0.3.0}/ruling/cli.py +15 -2
- {ruling-0.2.0 → ruling-0.3.0}/ruling/history.html +23 -1
- {ruling-0.2.0 → ruling-0.3.0}/ruling/questions.py +8 -1
- {ruling-0.2.0 → ruling-0.3.0}/ruling/retrain.py +35 -9
- ruling-0.3.0/ruling/sdk.py +177 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/server.py +186 -25
- {ruling-0.2.0 → ruling-0.3.0}/ruling/train.py +46 -9
- ruling-0.3.0/ruling/usage.py +75 -0
- ruling-0.2.0/ruling/sdk.py +0 -52
- {ruling-0.2.0 → ruling-0.3.0}/.gitignore +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/LICENSE +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/against.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/build.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/calibration.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/cascade.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/codes.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/config.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/corpus.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/decision.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/decision_train.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/distill.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/engine.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/evals.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/failover.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/hosted.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/ledger.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/metrics.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/modernbert.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/peft.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/playground.html +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/prompt.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/registry.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/states.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/thinking.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/vision.py +0 -0
- {ruling-0.2.0 → ruling-0.3.0}/ruling/worlds.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ruling
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Open-weight recreation of TypeSafe AI's Jev: typed, probabilistic decisions from a local model, no text generation.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bradAGI/ruling
|
|
6
6
|
Project-URL: Source, https://github.com/bradAGI/ruling
|
|
@@ -18,6 +18,8 @@ Requires-Dist: transformers>=5.17.0
|
|
|
18
18
|
Requires-Dist: uvicorn>=0.30
|
|
19
19
|
Provides-Extra: vision
|
|
20
20
|
Requires-Dist: mlx-vlm>=0.7.8; extra == 'vision'
|
|
21
|
+
Provides-Extra: wandb
|
|
22
|
+
Requires-Dist: wandb>=0.19; extra == 'wandb'
|
|
21
23
|
Description-Content-Type: text/markdown
|
|
22
24
|
|
|
23
25
|
<p align="center">
|
|
@@ -52,15 +54,16 @@ is ahead. Not affiliated with TypeSafe AI.
|
|
|
52
54
|
|
|
53
55
|
## Installation
|
|
54
56
|
|
|
55
|
-
You need an Apple Silicon Mac
|
|
57
|
+
You need an Apple Silicon Mac and Python 3.12+.
|
|
56
58
|
|
|
57
59
|
```bash
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
uv run ruling serve # downloads a 2.3 GB model on first start, then serves http://127.0.0.1:8010
|
|
60
|
+
pip install ruling # or: uv tool install ruling; add [vision] to ask about images
|
|
61
|
+
ruling serve # downloads a 2.3 GB model on first start, then serves http://127.0.0.1:8010
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
Open `http://127.0.0.1:8010/` for the playground.
|
|
64
|
+
Open `http://127.0.0.1:8010/` for the playground. To work on ruling itself,
|
|
65
|
+
clone the repository and `uv sync`; see
|
|
66
|
+
[Development](docs/development.md). On NVIDIA, see
|
|
64
67
|
[vLLM](docs/reference.md#on-nvidia-vllm). Before relying on an answer, read the
|
|
65
68
|
[limits](docs/getting-started.md#limits).
|
|
66
69
|
|
|
@@ -30,15 +30,16 @@ is ahead. Not affiliated with TypeSafe AI.
|
|
|
30
30
|
|
|
31
31
|
## Installation
|
|
32
32
|
|
|
33
|
-
You need an Apple Silicon Mac
|
|
33
|
+
You need an Apple Silicon Mac and Python 3.12+.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
uv run ruling serve # downloads a 2.3 GB model on first start, then serves http://127.0.0.1:8010
|
|
36
|
+
pip install ruling # or: uv tool install ruling; add [vision] to ask about images
|
|
37
|
+
ruling serve # downloads a 2.3 GB model on first start, then serves http://127.0.0.1:8010
|
|
39
38
|
```
|
|
40
39
|
|
|
41
|
-
Open `http://127.0.0.1:8010/` for the playground.
|
|
40
|
+
Open `http://127.0.0.1:8010/` for the playground. To work on ruling itself,
|
|
41
|
+
clone the repository and `uv sync`; see
|
|
42
|
+
[Development](docs/development.md). On NVIDIA, see
|
|
42
43
|
[vLLM](docs/reference.md#on-nvidia-vllm). Before relying on an answer, read the
|
|
43
44
|
[limits](docs/getting-started.md#limits).
|
|
44
45
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ruling"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Open-weight recreation of TypeSafe AI's Jev: typed, probabilistic decisions from a local model, no text generation."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -28,6 +28,9 @@ ruling = "ruling.cli:main"
|
|
|
28
28
|
vision = [
|
|
29
29
|
"mlx-vlm>=0.7.8",
|
|
30
30
|
]
|
|
31
|
+
wandb = [
|
|
32
|
+
"wandb>=0.19",
|
|
33
|
+
]
|
|
31
34
|
|
|
32
35
|
[dependency-groups]
|
|
33
36
|
dev = [
|
|
@@ -10,14 +10,17 @@ from ruling.questions import (
|
|
|
10
10
|
SystemOneRequest,
|
|
11
11
|
SystemOneResponse,
|
|
12
12
|
)
|
|
13
|
-
from ruling.sdk import RulingClient
|
|
13
|
+
from ruling.sdk import Image, RetryPolicy, RulingClient, RulingError
|
|
14
14
|
|
|
15
15
|
__all__ = [
|
|
16
16
|
"Choice",
|
|
17
17
|
"ChoiceAnswer",
|
|
18
|
+
"Image",
|
|
18
19
|
"Noul",
|
|
19
20
|
"NoulAnswer",
|
|
21
|
+
"RetryPolicy",
|
|
20
22
|
"RulingClient",
|
|
23
|
+
"RulingError",
|
|
21
24
|
"Score",
|
|
22
25
|
"ScoreAnswer",
|
|
23
26
|
"SystemOneRequest",
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import contextlib
|
|
6
7
|
import dataclasses
|
|
8
|
+
import io
|
|
7
9
|
import json
|
|
8
10
|
import logging
|
|
11
|
+
import sys
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
|
|
11
14
|
import numpy as np
|
|
@@ -117,6 +120,9 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
117
120
|
help="continue from the checkpoint in --output; only --steps may differ from the original run")
|
|
118
121
|
train.add_argument("--from-adapter", type=Path,
|
|
119
122
|
help="start a fresh run from this adapter's weights; its LoRA settings must match")
|
|
123
|
+
train.add_argument("--wandb", nargs="?", const="", metavar="PROJECT",
|
|
124
|
+
help="mirror every progress entry to a Weights & Biases run, in PROJECT or the account's default "
|
|
125
|
+
"(needs `pip install 'ruling[wandb]'`)")
|
|
120
126
|
|
|
121
127
|
export = commands.add_parser("export-adapter", help="write a ruling adapter in PEFT layout for transformers")
|
|
122
128
|
export.add_argument("adapter", type=Path, help="a LoRA directory from `ruling train`")
|
|
@@ -162,6 +168,9 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
162
168
|
|
|
163
169
|
args = parser.parse_args(argv)
|
|
164
170
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
|
171
|
+
# Progress lines are read from log files while a run is going; block buffering would hold them back.
|
|
172
|
+
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
173
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
165
174
|
configure_memory(Settings.from_env())
|
|
166
175
|
|
|
167
176
|
if args.command == "serve":
|
|
@@ -238,7 +247,7 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
238
247
|
print(f"wrote {len(records)} records to {args.output}")
|
|
239
248
|
return
|
|
240
249
|
if args.command == "train":
|
|
241
|
-
from ruling.train import Trainer, TrainConfig
|
|
250
|
+
from ruling.train import Trainer, TrainConfig, WandbSink
|
|
242
251
|
options = {name.replace("-", "_"): getattr(args, name.replace("-", "_"))
|
|
243
252
|
for name in ("valid-fraction", "steps", "batch-size", "learning-rate", "max-grad-norm", "warmup", "lora-layers", "lora-rank",
|
|
244
253
|
"lora-scale", "lora-dropout", "max-tokens", "eval-every", "valid-examples", "seed",
|
|
@@ -246,7 +255,11 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
246
255
|
config = TrainConfig(model=args.model or Settings.from_env().model, data=args.data, output=args.output,
|
|
247
256
|
holdout=args.holdout, grad_checkpoint=args.grad_checkpoint, from_adapter=args.from_adapter,
|
|
248
257
|
**{k: v for k, v in options.items() if v is not None})
|
|
249
|
-
|
|
258
|
+
# The sink is made before the model loads, so a missing package is reported at once.
|
|
259
|
+
sink = contextlib.nullcontext() if args.wandb is None else WandbSink(args.wandb or None, config)
|
|
260
|
+
trainer = Trainer(config, resume=args.resume)
|
|
261
|
+
with sink as entries:
|
|
262
|
+
trainer.run(sink=entries)
|
|
250
263
|
return
|
|
251
264
|
if args.command == "retrain":
|
|
252
265
|
from ruling.ledger import DecisionLedger
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
.kept { color: var(--muted); }
|
|
24
24
|
.serving { font-weight: bold; }
|
|
25
25
|
.err { color: var(--accent); }
|
|
26
|
+
svg.curve { display: block; color: var(--bar); }
|
|
27
|
+
.kept svg.curve { color: var(--muted); }
|
|
26
28
|
</style>
|
|
27
29
|
</head>
|
|
28
30
|
<body>
|
|
@@ -41,6 +43,23 @@ function text(value) {
|
|
|
41
43
|
span.textContent = value;
|
|
42
44
|
return span.innerHTML;
|
|
43
45
|
}
|
|
46
|
+
const WIDTH = 120, HEIGHT = 28, PAD = 2;
|
|
47
|
+
function curve(training) {
|
|
48
|
+
// Validation loss over steps, both axes scaled to the run; a flat run or a lone point sits on the middle line.
|
|
49
|
+
const steps = training.map(e => e.step), losses = training.map(e => e.loss);
|
|
50
|
+
const x0 = Math.min(...steps), x1 = Math.max(...steps), y0 = Math.min(...losses), y1 = Math.max(...losses);
|
|
51
|
+
const x = s => x1 === x0 ? WIDTH / 2 : PAD + (s - x0) / (x1 - x0) * (WIDTH - 2 * PAD);
|
|
52
|
+
const y = l => y1 === y0 ? HEIGHT / 2 : HEIGHT - PAD - (l - y0) / (y1 - y0) * (HEIGHT - 2 * PAD);
|
|
53
|
+
const points = training.map(e => `${x(e.step).toFixed(1)},${y(e.loss).toFixed(1)}`).join(" ");
|
|
54
|
+
const last = training[training.length - 1];
|
|
55
|
+
return `<svg class="curve" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}" role="img" ` +
|
|
56
|
+
`aria-label="validation loss ${pct(training[0].loss)} to ${pct(last.loss)} over ${last.step} steps">` +
|
|
57
|
+
`<polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`;
|
|
58
|
+
}
|
|
59
|
+
function finalLoss(training) {
|
|
60
|
+
const last = training[training.length - 1];
|
|
61
|
+
return `${pct(last.train_loss)} / ${pct(last.loss)}`;
|
|
62
|
+
}
|
|
44
63
|
let key = "";
|
|
45
64
|
try { key = sessionStorage.getItem("ruling-key") || ""; } catch {}
|
|
46
65
|
fetch("/v1/retrain", { headers: key ? { authorization: `Bearer ${key}` } : {} }).then(async r => {
|
|
@@ -59,13 +78,16 @@ fetch("/v1/retrain", { headers: key ? { authorization: `Bearer ${key}` } : {} })
|
|
|
59
78
|
html += `<h2>${text(m.name)} <span class="meta">serving ${text(m.current ?? "the base model")}</span></h2>`;
|
|
60
79
|
if (!m.versions.length) { html += '<div class="meta">no finished runs yet</div>'; continue; }
|
|
61
80
|
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
|
|
81
|
+
'<th class="num">held-out accuracy</th><th class="num">held-out log loss</th>' +
|
|
82
|
+
'<th>validation loss</th><th class="num">final loss train / valid</th><th>verdict</th></tr></thead><tbody>';
|
|
63
83
|
for (const v of [...m.versions].reverse()) {
|
|
64
84
|
const serving = v.version === m.current ? " serving" : "";
|
|
65
85
|
html += `<tr class="${v.promoted ? "promoted" : "kept"}${serving}" data-version="${text(v.version)}">` +
|
|
66
86
|
`<td>${text(v.version)}</td><td>${text(v.started_from ?? "base")}</td>` +
|
|
67
87
|
`<td class="num">${v.records}</td><td class="num">${change(v.current, v.candidate, "accuracy")}</td>` +
|
|
68
88
|
`<td class="num">${change(v.current, v.candidate, "nll")}</td>` +
|
|
89
|
+
`<td>${v.training?.length ? curve(v.training) : "–"}</td>` +
|
|
90
|
+
`<td class="num">${v.training?.length ? finalLoss(v.training) : "–"}</td>` +
|
|
69
91
|
`<td>${v.promoted ? "promoted" : "kept"}: ${text(v.reason)}</td></tr>`;
|
|
70
92
|
}
|
|
71
93
|
html += "</tbody></table></div>";
|
|
@@ -27,6 +27,13 @@ MAX_QUESTIONS = 64
|
|
|
27
27
|
MAX_IMAGES = 8
|
|
28
28
|
MAX_IMAGE_BYTES = 5 * 2**20
|
|
29
29
|
IMAGE_REFERENCE = "<image {}>"
|
|
30
|
+
ImageMediaType = Literal["image/png", "image/jpeg", "image/webp"]
|
|
31
|
+
IMAGE_SUFFIXES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
|
|
32
|
+
"""The media type an image file's suffix implies, for clients reading images off disk."""
|
|
33
|
+
|
|
34
|
+
MAX_PREDICT_ROWS = 50_000
|
|
35
|
+
"""Rows one `/v1/predict` or `/v1/evaluations` request may carry. A batch holds one admission slot
|
|
36
|
+
for its whole duration, so the limit bounds how long one client can keep a slot."""
|
|
30
37
|
|
|
31
38
|
# The hosted SDK sends its default model name on every request. Treating these as
|
|
32
39
|
# "whichever model this server loaded" is what lets that client talk to ruling
|
|
@@ -110,7 +117,7 @@ def question_statement(question: Question) -> str:
|
|
|
110
117
|
|
|
111
118
|
class Image(Strict):
|
|
112
119
|
type: Literal["image"]
|
|
113
|
-
media_type:
|
|
120
|
+
media_type: ImageMediaType
|
|
114
121
|
data: str
|
|
115
122
|
|
|
116
123
|
@field_validator("data")
|
|
@@ -6,9 +6,13 @@ a relative link to the version being served (`RULING_ADAPTER=<dir>/current`).
|
|
|
6
6
|
A run exports the ledger's resolved decisions, drops any whose state is in a
|
|
7
7
|
held-out set, trains the next version (from `current`'s weights when there is
|
|
8
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.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
when the new one has lower log loss without losing accuracy. Each version is
|
|
10
|
+
scored at its own fitted temperature: training sharpens an adapter faster than
|
|
11
|
+
it corrects it, and a version that is right more often must not lose for being
|
|
12
|
+
overconfident. The candidate's temperatures are saved beside its weights, so
|
|
13
|
+
`RULING_CALIBRATION=<dir>/current/calibration.json` serves the promoted
|
|
14
|
+
version calibrated. Every run leaves its records, its training log and the
|
|
15
|
+
comparison in its version directory, promoted or not.
|
|
12
16
|
"""
|
|
13
17
|
|
|
14
18
|
from __future__ import annotations
|
|
@@ -21,16 +25,17 @@ from pathlib import Path
|
|
|
21
25
|
|
|
22
26
|
import mlx.core as mx
|
|
23
27
|
|
|
24
|
-
from ruling.calibration import Calibration
|
|
28
|
+
from ruling.calibration import Calibration, Provenance
|
|
25
29
|
from ruling.config import Settings
|
|
26
30
|
from ruling.engine import ADAPTER_CONFIG
|
|
27
|
-
from ruling.evals import Record, collect, decision_blocks, load_dataset
|
|
31
|
+
from ruling.evals import Observation, Record, collect, decision_blocks, fit, load_dataset
|
|
28
32
|
from ruling.ledger import DecisionLedger
|
|
29
33
|
from ruling.registry import build_engine
|
|
30
|
-
from ruling.train import Trainer, TrainConfig, _state_key
|
|
34
|
+
from ruling.train import TRAINING, Trainer, TrainConfig, _state_key
|
|
31
35
|
|
|
32
36
|
CURRENT = "current"
|
|
33
37
|
REPORT = "retrain.json"
|
|
38
|
+
CALIBRATION = "calibration.json"
|
|
34
39
|
DECISIONS = "decisions.jsonl"
|
|
35
40
|
VERSION = re.compile(r"v(\d+)")
|
|
36
41
|
|
|
@@ -45,6 +50,15 @@ def should_promote(current: dict, candidate: dict) -> tuple[bool, str]:
|
|
|
45
50
|
f"accuracy {current['accuracy']:.4f} -> {candidate['accuracy']:.4f}")
|
|
46
51
|
|
|
47
52
|
|
|
53
|
+
def score(observations: list[Observation], provenance: Provenance) -> tuple[dict, Calibration]:
|
|
54
|
+
"""Held-out metrics at the temperatures that fit these observations best, and that calibration.
|
|
55
|
+
|
|
56
|
+
A temperature never changes which answer wins, so accuracy is the raw model's; log loss
|
|
57
|
+
becomes a measure of how well the model ranks answers, not of how sharply it states them."""
|
|
58
|
+
calibration = fit(observations, provenance)
|
|
59
|
+
return decision_blocks(observations, calibration)["overall"], calibration
|
|
60
|
+
|
|
61
|
+
|
|
48
62
|
def next_version(adapters: Path) -> Path:
|
|
49
63
|
numbers = [int(m.group(1)) for p in adapters.iterdir() if (m := VERSION.fullmatch(p.name))] if adapters.exists() else []
|
|
50
64
|
return adapters / f"v{max(numbers, default=0) + 1}"
|
|
@@ -90,8 +104,11 @@ def retrain(ledger: DecisionLedger, adapters: Path, model: str, holdout: list[Pa
|
|
|
90
104
|
served = build_engine(replace(settings, model=model, adapter_path=start, calibration_path=None, cascade_to=None,
|
|
91
105
|
failover_to=None))
|
|
92
106
|
candidate = served.variant(version, Calibration())
|
|
93
|
-
|
|
107
|
+
scored = {name: score(collect(engine, held_out), engine.provenance)
|
|
94
108
|
for name, engine in (("current", served), ("candidate", candidate))}
|
|
109
|
+
scored["candidate"][1].save(version / CALIBRATION)
|
|
110
|
+
scores = {name: {**metrics, "temperatures": calibration.temperatures}
|
|
111
|
+
for name, (metrics, calibration) in scored.items()}
|
|
95
112
|
promoted, reason = should_promote(scores["current"], scores["candidate"])
|
|
96
113
|
report = {"version": version.name, "started_from": start.name if start else None, "records": len(records),
|
|
97
114
|
"dropped_held_out": dropped, "holdout": [str(p) for p in holdout], **scores,
|
|
@@ -104,17 +121,26 @@ def retrain(ledger: DecisionLedger, adapters: Path, model: str, holdout: list[Pa
|
|
|
104
121
|
|
|
105
122
|
|
|
106
123
|
def history(adapters: Path) -> dict:
|
|
107
|
-
"""Every finished run's report in version order, and the version `current`
|
|
124
|
+
"""Every finished run's report in version order, each with its training curve, and the version `current`
|
|
125
|
+
points at.
|
|
108
126
|
|
|
109
127
|
A version still training, or one whose run failed, has no report and is left out."""
|
|
110
128
|
link = adapters / CURRENT
|
|
111
|
-
reports = [
|
|
129
|
+
reports = [_report(version)
|
|
112
130
|
for version in sorted((p for p in adapters.iterdir() if VERSION.fullmatch(p.name)),
|
|
113
131
|
key=lambda p: int(p.name[1:]))
|
|
114
132
|
if (version / REPORT).exists()] if adapters.exists() else []
|
|
115
133
|
return {"current": link.resolve().name if link.exists() else None, "versions": reports}
|
|
116
134
|
|
|
117
135
|
|
|
136
|
+
def _report(version: Path) -> dict:
|
|
137
|
+
"""The version's report, with the history its trainer logged under `training` when the log is there."""
|
|
138
|
+
report = json.loads((version / REPORT).read_text())
|
|
139
|
+
if (version / TRAINING).exists():
|
|
140
|
+
report["training"] = json.loads((version / TRAINING).read_text())["history"]
|
|
141
|
+
return report
|
|
142
|
+
|
|
143
|
+
|
|
118
144
|
def _lora_layout(adapter: Path | None) -> dict:
|
|
119
145
|
"""The LoRA settings to train with: the starting adapter's, which its weights only fit, else the trainer's defaults."""
|
|
120
146
|
if adapter is None:
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Python client mirroring the shape of the hosted System One SDK, plus ruling's batch, evaluation and usage routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import gzip
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from time import sleep
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import TypeAdapter
|
|
16
|
+
|
|
17
|
+
from ruling.questions import (
|
|
18
|
+
IMAGE_SUFFIXES,
|
|
19
|
+
Answer,
|
|
20
|
+
Choice,
|
|
21
|
+
ModelInfo,
|
|
22
|
+
ModelList,
|
|
23
|
+
Noul,
|
|
24
|
+
Score,
|
|
25
|
+
State,
|
|
26
|
+
SystemOneRequest,
|
|
27
|
+
SystemOneResponse,
|
|
28
|
+
)
|
|
29
|
+
from ruling.questions import Image as ImageBlock
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8010"
|
|
32
|
+
|
|
33
|
+
GZIP_THRESHOLD = 64 * 1024
|
|
34
|
+
"""Bodies of at least this many bytes are sent gzipped. JSON rows compress several times over, and
|
|
35
|
+
below this size the round trip is dominated by the model, not the bytes."""
|
|
36
|
+
|
|
37
|
+
RETRIED_STATUSES = (429, 503)
|
|
38
|
+
"""A full queue and an absent upstream are the server's two transient refusals."""
|
|
39
|
+
|
|
40
|
+
ANSWER = TypeAdapter(Answer)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RetryPolicy:
|
|
45
|
+
"""How a request that was turned away or never arrived is tried again: at most `max_retries`
|
|
46
|
+
more times, waiting the server's `Retry-After` when it gives one, else doubling from
|
|
47
|
+
`backoff_initial` seconds up to `backoff_max`."""
|
|
48
|
+
|
|
49
|
+
max_retries: int = 2
|
|
50
|
+
backoff_initial: float = 0.5
|
|
51
|
+
backoff_max: float = 5.0
|
|
52
|
+
|
|
53
|
+
def delay(self, attempt: int, retry_after: str | None) -> float:
|
|
54
|
+
if retry_after is not None:
|
|
55
|
+
return float(retry_after)
|
|
56
|
+
return min(self.backoff_initial * 2**attempt, self.backoff_max)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RulingError(Exception):
|
|
60
|
+
"""A refusal from the server: its HTTP `status` and the `code`, `message` and `param` of its error body."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, status: int, code: str, message: str, param: str | None = None):
|
|
63
|
+
super().__init__(f"{status} {code}: {message}" + (f" (param {param})" if param else ""))
|
|
64
|
+
self.status, self.code, self.message, self.param = status, code, message, param
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_response(cls, response: httpx.Response) -> "RulingError":
|
|
68
|
+
try:
|
|
69
|
+
error = response.json()["error"]
|
|
70
|
+
except (ValueError, KeyError, TypeError):
|
|
71
|
+
# Something between the client and ruling answered, such as a proxy's HTML error page.
|
|
72
|
+
return cls(response.status_code, "http_error", response.text)
|
|
73
|
+
return cls(response.status_code, error["code"], error["message"], error.get("param"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Image:
|
|
77
|
+
"""Image blocks for a state, in the wire shape the server reads."""
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def from_bytes(data: bytes, media_type: str) -> dict:
|
|
81
|
+
block = ImageBlock(type="image", media_type=media_type, data=base64.b64encode(data).decode())
|
|
82
|
+
return block.model_dump()
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def from_path(path: str | os.PathLike) -> dict:
|
|
86
|
+
path = Path(path)
|
|
87
|
+
try:
|
|
88
|
+
media_type = IMAGE_SUFFIXES[path.suffix.lower()]
|
|
89
|
+
except KeyError:
|
|
90
|
+
raise ValueError(f"{path.name}: an image must be one of {', '.join(IMAGE_SUFFIXES)}, "
|
|
91
|
+
f"got {path.suffix or 'no suffix'}") from None
|
|
92
|
+
return Image.from_bytes(path.read_bytes(), media_type)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RulingClient:
|
|
96
|
+
def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 60.0,
|
|
97
|
+
retry: RetryPolicy | None = None, gzip: bool = True,
|
|
98
|
+
transport: httpx.BaseTransport | None = None):
|
|
99
|
+
self.base_url = base_url or os.environ.get("RULING_BASE_URL", DEFAULT_BASE_URL)
|
|
100
|
+
# The server's own RULING_API_KEY, so one environment configures both ends.
|
|
101
|
+
api_key = api_key or os.environ.get("RULING_API_KEY")
|
|
102
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
103
|
+
self.retry = retry if retry is not None else RetryPolicy()
|
|
104
|
+
self.gzip = gzip
|
|
105
|
+
self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout, transport=transport)
|
|
106
|
+
|
|
107
|
+
def system_one(
|
|
108
|
+
self, state: State, questions: dict[str, Choice | Score | Noul], model: str | None = None,
|
|
109
|
+
exclude_from_training: bool = False,
|
|
110
|
+
) -> SystemOneResponse:
|
|
111
|
+
request = SystemOneRequest(model=model, state=state, questions=questions,
|
|
112
|
+
exclude_from_training=exclude_from_training)
|
|
113
|
+
# The flag goes only when set, so every other body is exactly System One's.
|
|
114
|
+
body = request.model_dump(mode="json", exclude=None if exclude_from_training else {"exclude_from_training"})
|
|
115
|
+
return SystemOneResponse.model_validate(self._send("POST", "/v1/systemone", body).json())
|
|
116
|
+
|
|
117
|
+
def predict(self, rows: list[dict], model: str | None = None) -> list[dict]:
|
|
118
|
+
"""Every row answered in order: `{"id"?, "answers": {id: Answer}}`, or `{"id"?, "error": {...}}` for
|
|
119
|
+
a row the server refused on its own."""
|
|
120
|
+
body = self._send("POST", "/v1/predict", {"model": model, "rows": rows}).json()
|
|
121
|
+
return [{**entry, "answers": {qid: ANSWER.validate_python(a) for qid, a in entry["answers"].items()}}
|
|
122
|
+
if "answers" in entry else entry
|
|
123
|
+
for entry in body["answers"]]
|
|
124
|
+
|
|
125
|
+
def evaluate(self, rows: list[dict], model: str | None = None) -> dict:
|
|
126
|
+
"""The labeled rows scored: the report `ruling eval` prints, with the answering model's name."""
|
|
127
|
+
return self._send("POST", "/v1/evaluations", {"model": model, "rows": rows}).json()
|
|
128
|
+
|
|
129
|
+
def usage(self, starting_on: datetime | None = None, ending_before: datetime | None = None) -> list[dict]:
|
|
130
|
+
"""Hourly buckets per model since the server started, within the window when one is given."""
|
|
131
|
+
params = {name: moment.isoformat() for name, moment in
|
|
132
|
+
(("starting_on", starting_on), ("ending_before", ending_before)) if moment is not None}
|
|
133
|
+
return self._send("GET", "/v1/usage", params=params).json()["usage"]
|
|
134
|
+
|
|
135
|
+
def outcome(self, decision_id: str, labels: dict) -> dict:
|
|
136
|
+
"""Report what turned out to be right for a logged decision; returns the labels as stored."""
|
|
137
|
+
return self._send("POST", f"/v1/decisions/{decision_id}/outcome", {"labels": labels}).json()["labels"]
|
|
138
|
+
|
|
139
|
+
def models(self) -> list[ModelInfo]:
|
|
140
|
+
return ModelList.model_validate(self._send("GET", "/v1/models").json()).models
|
|
141
|
+
|
|
142
|
+
def _send(self, method: str, path: str, body: dict | None = None, params: dict | None = None) -> httpx.Response:
|
|
143
|
+
content, headers = self._encode(body)
|
|
144
|
+
for attempt in range(self.retry.max_retries + 1):
|
|
145
|
+
last = attempt == self.retry.max_retries
|
|
146
|
+
try:
|
|
147
|
+
response = self._http.request(method, path, content=content, headers=headers, params=params)
|
|
148
|
+
except httpx.TransportError:
|
|
149
|
+
if last:
|
|
150
|
+
raise
|
|
151
|
+
sleep(self.retry.delay(attempt, None))
|
|
152
|
+
continue
|
|
153
|
+
if response.status_code in RETRIED_STATUSES and not last:
|
|
154
|
+
sleep(self.retry.delay(attempt, response.headers.get("retry-after")))
|
|
155
|
+
continue
|
|
156
|
+
if response.is_error:
|
|
157
|
+
raise RulingError.from_response(response)
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
def _encode(self, body: dict | None) -> tuple[bytes | None, dict[str, str]]:
|
|
161
|
+
if body is None:
|
|
162
|
+
return None, {}
|
|
163
|
+
content = json.dumps(body).encode()
|
|
164
|
+
headers = {"content-type": "application/json"}
|
|
165
|
+
if self.gzip and len(content) >= GZIP_THRESHOLD:
|
|
166
|
+
content = gzip.compress(content)
|
|
167
|
+
headers["content-encoding"] = "gzip"
|
|
168
|
+
return content, headers
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
self._http.close()
|
|
172
|
+
|
|
173
|
+
def __enter__(self) -> "RulingClient":
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def __exit__(self, *exc) -> None:
|
|
177
|
+
self.close()
|
|
@@ -1,24 +1,37 @@
|
|
|
1
|
-
"""HTTP surface: one
|
|
1
|
+
"""HTTP surface: decisions one at a time or in batches over a registry of models, scored evaluations,
|
|
2
|
+
usage counts, model listing, health, and a playground page."""
|
|
2
3
|
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
6
|
+
import gzip
|
|
5
7
|
import logging
|
|
8
|
+
from importlib.metadata import version
|
|
6
9
|
import math
|
|
7
10
|
import threading
|
|
8
11
|
import time
|
|
12
|
+
import zlib
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from datetime import datetime
|
|
9
16
|
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
10
18
|
|
|
11
19
|
from fastapi import Depends, FastAPI, Request
|
|
12
20
|
from fastapi.concurrency import run_in_threadpool
|
|
13
21
|
from fastapi.exceptions import RequestValidationError
|
|
14
22
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
15
23
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
24
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
25
|
+
from starlette.datastructures import Headers
|
|
26
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
16
27
|
|
|
17
28
|
from ruling.config import Settings
|
|
18
29
|
from ruling.engine import InputTooLong
|
|
30
|
+
from ruling.evals import Record, as_record, collect, predict, report
|
|
19
31
|
from ruling.hosted import HostUnavailable
|
|
20
32
|
from ruling.ledger import DecisionLedger, Outcome, UnknownDecision
|
|
21
33
|
from ruling.questions import (
|
|
34
|
+
MAX_PREDICT_ROWS,
|
|
22
35
|
InvalidOptions,
|
|
23
36
|
ModelList,
|
|
24
37
|
SystemOneRequest,
|
|
@@ -29,6 +42,7 @@ from ruling.questions import (
|
|
|
29
42
|
)
|
|
30
43
|
from ruling.registry import Registry, UnknownModel
|
|
31
44
|
from ruling.retrain import CURRENT, history
|
|
45
|
+
from ruling.usage import Metered, UsageMeter
|
|
32
46
|
|
|
33
47
|
log = logging.getLogger("ruling")
|
|
34
48
|
PLAYGROUND = Path(__file__).with_name("playground.html")
|
|
@@ -86,12 +100,145 @@ class Admission:
|
|
|
86
100
|
estimate = self.pending * (self.seconds_per_request or 0.0)
|
|
87
101
|
return max(1, math.ceil(estimate))
|
|
88
102
|
|
|
103
|
+
@contextmanager
|
|
104
|
+
def slot(self) -> Iterator[Callable[[], float]]:
|
|
105
|
+
"""Hold one place in the queue for the block, or refuse with a wait; yields the elapsed time so far."""
|
|
106
|
+
if not self.enter():
|
|
107
|
+
raise ApiError(429, "queue_full", f"{self.limit} requests are already in flight",
|
|
108
|
+
retry_after=self.retry_after())
|
|
109
|
+
started = time.perf_counter()
|
|
110
|
+
|
|
111
|
+
def elapsed() -> float:
|
|
112
|
+
return time.perf_counter() - started
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
yield elapsed
|
|
116
|
+
finally:
|
|
117
|
+
self.leave(elapsed())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GzipRequests:
|
|
121
|
+
"""Inflates a request body sent with `Content-Encoding: gzip`, so a client can shrink a large batch."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, app: ASGIApp):
|
|
124
|
+
self.app = app
|
|
125
|
+
|
|
126
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
127
|
+
if scope["type"] != "http" or Headers(scope=scope).get("content-encoding") != "gzip":
|
|
128
|
+
await self.app(scope, receive, send)
|
|
129
|
+
return
|
|
130
|
+
packed = b""
|
|
131
|
+
while True:
|
|
132
|
+
message = await receive()
|
|
133
|
+
packed += message.get("body", b"")
|
|
134
|
+
if not message.get("more_body"):
|
|
135
|
+
break
|
|
136
|
+
try:
|
|
137
|
+
body = gzip.decompress(packed)
|
|
138
|
+
except (gzip.BadGzipFile, EOFError, zlib.error) as exc:
|
|
139
|
+
await ApiError(422, "invalid_input", f"the body is not valid gzip: {exc}").response()(scope, receive, send)
|
|
140
|
+
return
|
|
141
|
+
headers = [(k, v) for k, v in scope["headers"] if k not in (b"content-encoding", b"content-length")]
|
|
142
|
+
headers.append((b"content-length", str(len(body)).encode()))
|
|
143
|
+
|
|
144
|
+
async def receive_inflated() -> dict:
|
|
145
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
146
|
+
|
|
147
|
+
await self.app({**scope, "headers": headers}, receive_inflated, send)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class BatchRequest(BaseModel):
|
|
151
|
+
"""Rows in any shape `ruling.evals` reads for `ruling predict`; a row's shape is checked row by row."""
|
|
152
|
+
|
|
153
|
+
model: str | None = None
|
|
154
|
+
rows: list[Any] = Field(min_length=1, max_length=MAX_PREDICT_ROWS)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_row(row: Any) -> Record:
|
|
158
|
+
if not isinstance(row, dict):
|
|
159
|
+
raise ValueError("a row must be an object")
|
|
160
|
+
return Record.model_validate(as_record(row))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def row_error(exc: ValueError) -> dict[str, str]:
|
|
164
|
+
"""The API's code for what refused a row. A schema failure is reported by its first problem; one a
|
|
165
|
+
record's own validator raised, such as a one-option choice, keeps the code that error carries."""
|
|
166
|
+
if isinstance(exc, ValidationError):
|
|
167
|
+
first = exc.errors()[0]
|
|
168
|
+
cause = first.get("ctx", {}).get("error")
|
|
169
|
+
if isinstance(cause, ValueError):
|
|
170
|
+
return row_error(cause)
|
|
171
|
+
where = ".".join(str(part) for part in first["loc"])
|
|
172
|
+
return {"code": "invalid_input", "message": f"{where}: {first['msg']}" if where else first["msg"]}
|
|
173
|
+
if isinstance(exc, InputTooLong):
|
|
174
|
+
return {"code": "context_too_long", "message": str(exc)}
|
|
175
|
+
if isinstance(exc, InvalidOptions):
|
|
176
|
+
return {"code": "invalid_options", "message": str(exc)}
|
|
177
|
+
if isinstance(exc, UnsupportedModality):
|
|
178
|
+
return {"code": "unsupported_modality", "message": str(exc)}
|
|
179
|
+
return {"code": "invalid_input", "message": str(exc)}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def answer_rows(engine: Metered, rows: list[Any]) -> list[dict]:
|
|
183
|
+
"""One entry per row in order: its answers, or the error that refused that row alone."""
|
|
184
|
+
entries: list[dict | None] = [None] * len(rows)
|
|
185
|
+
records, places = [], []
|
|
186
|
+
for index, row in enumerate(rows):
|
|
187
|
+
try:
|
|
188
|
+
records.append(parse_row(row))
|
|
189
|
+
places.append(index)
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
named = isinstance(row, dict) and row.get("id") is not None
|
|
192
|
+
entries[index] = {**({"id": row["id"]} if named else {}), "error": row_error(exc)}
|
|
193
|
+
for index, line in zip(places, predict(engine, records)):
|
|
194
|
+
entries[index] = line
|
|
195
|
+
return entries
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def labeled_records(engine, rows: list[Any]) -> list[Record]:
|
|
199
|
+
"""Every row as a record the engine can score, or the 422 naming the first row it cannot."""
|
|
200
|
+
records = []
|
|
201
|
+
for index, row in enumerate(rows):
|
|
202
|
+
try:
|
|
203
|
+
record = parse_row(row)
|
|
204
|
+
check_askable(record.questions, engine.max_options)
|
|
205
|
+
check_readable(record.state, engine.reads_images)
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
raise ApiError(422, param=f"rows.{index}", **row_error(exc)) from exc
|
|
208
|
+
records.append(record)
|
|
209
|
+
if not any(record.labels for record in records):
|
|
210
|
+
raise ApiError(422, "invalid_input", "no row carries labels; nothing to score against", "rows")
|
|
211
|
+
return records
|
|
212
|
+
|
|
89
213
|
|
|
90
214
|
def create_app(registry: Registry, api_key: str | None, max_pending: int,
|
|
91
|
-
ledger: DecisionLedger | None = None) -> FastAPI:
|
|
92
|
-
app = FastAPI(title="ruling", version="
|
|
215
|
+
ledger: DecisionLedger | None = None, usage: UsageMeter | None = None) -> FastAPI:
|
|
216
|
+
app = FastAPI(title="ruling", version=version("ruling"))
|
|
217
|
+
app.add_middleware(GzipRequests)
|
|
93
218
|
bearer = HTTPBearer(auto_error=False)
|
|
94
219
|
admission = Admission(max_pending)
|
|
220
|
+
usage = usage if usage is not None else UsageMeter()
|
|
221
|
+
|
|
222
|
+
def resolve(model: str | None):
|
|
223
|
+
try:
|
|
224
|
+
return registry.resolve(model)
|
|
225
|
+
except UnknownModel as exc:
|
|
226
|
+
raise ApiError(404, "unknown_model", str(exc), "model") from exc
|
|
227
|
+
|
|
228
|
+
@contextmanager
|
|
229
|
+
def engine_errors() -> Iterator[None]:
|
|
230
|
+
"""What an engine raises while answering, in the API's shape."""
|
|
231
|
+
try:
|
|
232
|
+
yield
|
|
233
|
+
except InputTooLong as exc:
|
|
234
|
+
param = f"questions.{exc.question_id}" if exc.question_id is not None else None
|
|
235
|
+
raise ApiError(422, "context_too_long", str(exc), param) from exc
|
|
236
|
+
except InvalidOptions as exc:
|
|
237
|
+
raise ApiError(422, "invalid_options", str(exc), f"questions.{exc.question_id}") from exc
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
raise ApiError(422, "invalid_input", str(exc)) from exc
|
|
240
|
+
except HostUnavailable as exc:
|
|
241
|
+
raise ApiError(503, "upstream_unavailable", str(exc)) from exc
|
|
95
242
|
|
|
96
243
|
def authorize(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)) -> None:
|
|
97
244
|
if api_key is None:
|
|
@@ -113,10 +260,7 @@ def create_app(registry: Registry, api_key: str | None, max_pending: int,
|
|
|
113
260
|
@app.post("/v1/systemone", response_model=SystemOneResponse, response_model_exclude_none=True,
|
|
114
261
|
dependencies=[Depends(authorize)])
|
|
115
262
|
async def system_one(request: SystemOneRequest) -> SystemOneResponse:
|
|
116
|
-
|
|
117
|
-
name, engine = registry.resolve(request.model)
|
|
118
|
-
except UnknownModel as exc:
|
|
119
|
-
raise ApiError(404, "unknown_model", str(exc), "model") from exc
|
|
263
|
+
name, engine = resolve(request.model)
|
|
120
264
|
try:
|
|
121
265
|
check_askable(request.questions, engine.max_options)
|
|
122
266
|
except InvalidOptions as exc:
|
|
@@ -125,26 +269,11 @@ def create_app(registry: Registry, api_key: str | None, max_pending: int,
|
|
|
125
269
|
check_readable(request.state, engine.reads_images)
|
|
126
270
|
except UnsupportedModality as exc:
|
|
127
271
|
raise ApiError(422, "unsupported_modality", f"{name}: {exc}", "state") from exc
|
|
128
|
-
|
|
129
|
-
raise ApiError(429, "queue_full", f"{admission.limit} requests are already in flight",
|
|
130
|
-
retry_after=admission.retry_after())
|
|
131
|
-
started = time.perf_counter()
|
|
132
|
-
try:
|
|
272
|
+
with admission.slot() as elapsed, engine_errors():
|
|
133
273
|
response = await run_in_threadpool(engine.evaluate, request)
|
|
134
|
-
|
|
135
|
-
param = f"questions.{exc.question_id}" if exc.question_id is not None else None
|
|
136
|
-
raise ApiError(422, "context_too_long", str(exc), param) from exc
|
|
137
|
-
except InvalidOptions as exc:
|
|
138
|
-
raise ApiError(422, "invalid_options", str(exc), f"questions.{exc.question_id}") from exc
|
|
139
|
-
except ValueError as exc:
|
|
140
|
-
raise ApiError(422, "invalid_input", str(exc)) from exc
|
|
141
|
-
except HostUnavailable as exc:
|
|
142
|
-
raise ApiError(503, "upstream_unavailable", str(exc)) from exc
|
|
143
|
-
finally:
|
|
144
|
-
elapsed = time.perf_counter() - started
|
|
145
|
-
admission.leave(elapsed)
|
|
274
|
+
usage.record(name, 1, response.usage.input_tokens)
|
|
146
275
|
log.info("systemone model=%s questions=%d input_tokens=%d ms=%.0f",
|
|
147
|
-
name, len(request.questions), response.usage.input_tokens, elapsed * 1000)
|
|
276
|
+
name, len(request.questions), response.usage.input_tokens, elapsed() * 1000)
|
|
148
277
|
# An entry answers under its own name; the primary keeps the engine's report, which a
|
|
149
278
|
# failover changes to say which side answered.
|
|
150
279
|
if name != registry.default:
|
|
@@ -153,6 +282,38 @@ def create_app(registry: Registry, api_key: str | None, max_pending: int,
|
|
|
153
282
|
response = response.model_copy(update={"id": await run_in_threadpool(ledger.record, request, response)})
|
|
154
283
|
return response
|
|
155
284
|
|
|
285
|
+
@app.post("/v1/predict", dependencies=[Depends(authorize)])
|
|
286
|
+
async def predict_rows(request: BatchRequest) -> dict:
|
|
287
|
+
"""Every row answered in order; a row the model refuses carries its error and the batch goes on."""
|
|
288
|
+
name, engine = resolve(request.model)
|
|
289
|
+
metered = Metered(engine)
|
|
290
|
+
with admission.slot() as elapsed, engine_errors():
|
|
291
|
+
entries = await run_in_threadpool(answer_rows, metered, request.rows)
|
|
292
|
+
answered = sum("answers" in entry for entry in entries)
|
|
293
|
+
usage.record(name, answered, metered.input_tokens)
|
|
294
|
+
log.info("predict model=%s rows=%d answered=%d input_tokens=%d ms=%.0f",
|
|
295
|
+
name, len(entries), answered, metered.input_tokens, elapsed() * 1000)
|
|
296
|
+
return {"model": name, "answers": entries, "usage": {"input_tokens": metered.input_tokens, "output_tokens": 0}}
|
|
297
|
+
|
|
298
|
+
@app.post("/v1/evaluations", dependencies=[Depends(authorize)])
|
|
299
|
+
async def evaluations(request: BatchRequest) -> dict:
|
|
300
|
+
"""The rows scored against their labels: exactly the report `ruling eval` prints."""
|
|
301
|
+
name, engine = resolve(request.model)
|
|
302
|
+
metered = Metered(engine)
|
|
303
|
+
with admission.slot() as elapsed, engine_errors():
|
|
304
|
+
records = await run_in_threadpool(labeled_records, engine, request.rows)
|
|
305
|
+
observations = await run_in_threadpool(collect, metered, records)
|
|
306
|
+
scored = len({o.record for o in observations if o.logits is not None})
|
|
307
|
+
usage.record(name, scored, metered.input_tokens)
|
|
308
|
+
log.info("evaluations model=%s rows=%d scored=%d input_tokens=%d ms=%.0f",
|
|
309
|
+
name, len(records), scored, metered.input_tokens, elapsed() * 1000)
|
|
310
|
+
return {"model": name, **report(observations, engine.calibration)}
|
|
311
|
+
|
|
312
|
+
@app.get("/v1/usage", dependencies=[Depends(authorize)])
|
|
313
|
+
def usage_report(starting_on: datetime | None = None, ending_before: datetime | None = None) -> dict:
|
|
314
|
+
"""Hourly counts per model since the server started, kept in memory only."""
|
|
315
|
+
return {"usage": usage.buckets(starting_on, ending_before)}
|
|
316
|
+
|
|
156
317
|
if ledger is not None:
|
|
157
318
|
@app.post("/v1/decisions/{decision}/outcome", dependencies=[Depends(authorize)])
|
|
158
319
|
async def outcome(decision: str, body: Outcome) -> dict:
|
|
@@ -85,6 +85,7 @@ class TrainConfig:
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
CHECKPOINT = "checkpoint"
|
|
88
|
+
TRAINING = "training.json"
|
|
88
89
|
# Rendering happens in chunks of this many batches, which are then sorted by length and cut into batches.
|
|
89
90
|
CHUNK_BATCHES = 64
|
|
90
91
|
|
|
@@ -181,6 +182,36 @@ def config_mismatch(saved: dict, requested: dict) -> list[str]:
|
|
|
181
182
|
return sorted(k for k in set(saved) | set(requested) if k != "steps" and saved.get(k) != requested.get(k))
|
|
182
183
|
|
|
183
184
|
|
|
185
|
+
def progress(step: int, steps: int, elapsed: float) -> dict:
|
|
186
|
+
"""How far a run is and when it should end, rating the steps left at the run's pace so far.
|
|
187
|
+
|
|
188
|
+
`elapsed` covers every step since the run began, across resumes, so the pace is the whole run's."""
|
|
189
|
+
return {"elapsed_seconds": elapsed, "progress": step / steps, "eta_seconds": (steps - step) * elapsed / step}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class WandbSink:
|
|
193
|
+
"""Mirrors every history entry to a Weights & Biases run, opened on entering and finished on leaving,
|
|
194
|
+
marked failed when training raised. The package is imported here only, so it stays optional."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, project: str | None, config: TrainConfig):
|
|
197
|
+
try:
|
|
198
|
+
import wandb
|
|
199
|
+
except ImportError as error:
|
|
200
|
+
raise ImportError("Weights & Biases logging needs the wandb package: pip install 'ruling[wandb]'") from error
|
|
201
|
+
self.wandb, self.project, self.config = wandb, project, config
|
|
202
|
+
self.run = None
|
|
203
|
+
|
|
204
|
+
def __enter__(self) -> "WandbSink":
|
|
205
|
+
self.run = self.wandb.init(project=self.project, config=config_json(self.config))
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def __call__(self, entry: dict) -> None:
|
|
209
|
+
self.run.log(entry, step=entry["step"])
|
|
210
|
+
|
|
211
|
+
def __exit__(self, kind, value, traceback) -> None:
|
|
212
|
+
self.run.finish(exit_code=0 if kind is None else 1)
|
|
213
|
+
|
|
214
|
+
|
|
184
215
|
def save_checkpoint(directory: Path, weights: dict[str, mx.array], optimizer_state: dict, state: dict) -> None:
|
|
185
216
|
"""Everything a run needs to continue from its next step: LoRA weights, optimizer state and the trainer's bookkeeping."""
|
|
186
217
|
directory.mkdir(parents=True, exist_ok=True)
|
|
@@ -418,9 +449,10 @@ class Trainer:
|
|
|
418
449
|
meta = {"config": {**config_json(self.config), "data": data},
|
|
419
450
|
"base_revision": self.engine.revision, "train_records": len(self.train_records),
|
|
420
451
|
"train_examples": len(self.train_examples), "valid_examples": len(self.valid_examples), "history": history}
|
|
421
|
-
(directory /
|
|
452
|
+
(directory / TRAINING).write_text(json.dumps(meta, indent=2) + "\n")
|
|
422
453
|
|
|
423
|
-
def run(self, log=print) -> list[dict]:
|
|
454
|
+
def run(self, log=print, sink=None) -> list[dict]:
|
|
455
|
+
"""Train to `steps`, returning the history; `sink` receives each history entry as it is made."""
|
|
424
456
|
config = self.config
|
|
425
457
|
model = self.engine.model
|
|
426
458
|
model.train()
|
|
@@ -434,10 +466,16 @@ class Trainer:
|
|
|
434
466
|
trainable = sum(v.size for _, v in tree_flatten(model.trainable_parameters()))
|
|
435
467
|
log(f"trainable parameters {trainable:,}; {len(self.train_examples):,} train and {len(self.valid_examples):,} validation examples")
|
|
436
468
|
|
|
469
|
+
def record(entry: dict) -> None:
|
|
470
|
+
history.append(entry)
|
|
471
|
+
log(json.dumps(entry))
|
|
472
|
+
if sink is not None:
|
|
473
|
+
sink(entry)
|
|
474
|
+
|
|
437
475
|
batches = self._batches()
|
|
438
476
|
if self.checkpoint is None:
|
|
439
|
-
history = [
|
|
440
|
-
|
|
477
|
+
history = []
|
|
478
|
+
record({"step": 0, **self.validate()})
|
|
441
479
|
self.save(config.output, history)
|
|
442
480
|
first, tokens_seen, elapsed = 1, 0, 0.0
|
|
443
481
|
else:
|
|
@@ -464,17 +502,16 @@ class Trainer:
|
|
|
464
502
|
if step % config.eval_every == 0 or step == config.steps:
|
|
465
503
|
elapsed = time.perf_counter() - started
|
|
466
504
|
entry = {"step": step, "train_loss": float(np.mean(window)), "tokens_per_second": tokens_seen / elapsed,
|
|
467
|
-
**self.validate()}
|
|
505
|
+
**progress(step, config.steps, elapsed), **self.validate()}
|
|
468
506
|
window = []
|
|
469
|
-
|
|
470
|
-
log(json.dumps(entry))
|
|
507
|
+
record(entry)
|
|
471
508
|
if entry["loss"] < best:
|
|
472
509
|
best = entry["loss"]
|
|
473
510
|
self.save(config.output, history)
|
|
474
511
|
else:
|
|
475
|
-
meta = json.loads((config.output /
|
|
512
|
+
meta = json.loads((config.output / TRAINING).read_text())
|
|
476
513
|
meta["history"] = history
|
|
477
|
-
(config.output /
|
|
514
|
+
(config.output / TRAINING).write_text(json.dumps(meta, indent=2) + "\n")
|
|
478
515
|
save_checkpoint(config.output / CHECKPOINT, dict(tree_flatten(model.trainable_parameters())), optimizer.state,
|
|
479
516
|
{"step": step, "tokens_seen": tokens_seen, "elapsed": elapsed, "stream": batches.state(),
|
|
480
517
|
"history": history, "config": config_json(config)})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""What a server answered, counted per hour and per model.
|
|
2
|
+
|
|
3
|
+
The counts live in memory for the server's lifetime: a restart starts them
|
|
4
|
+
over, and nothing is written to disk. One decision is one `/v1/systemone`
|
|
5
|
+
request or one row of a `/v1/predict` or `/v1/evaluations` batch that was
|
|
6
|
+
answered; refused requests and rows count nothing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
HOUR = 3600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _Counts:
|
|
22
|
+
decisions: int = 0
|
|
23
|
+
input_tokens: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UsageMeter:
|
|
27
|
+
def __init__(self, now: Callable[[], float] = time.time):
|
|
28
|
+
self._now = now
|
|
29
|
+
self._buckets: dict[tuple[int, str], _Counts] = {}
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def record(self, model: str, decisions: int, input_tokens: int) -> None:
|
|
33
|
+
hour = int(self._now() // HOUR) * HOUR
|
|
34
|
+
with self._lock:
|
|
35
|
+
counts = self._buckets.setdefault((hour, model), _Counts())
|
|
36
|
+
counts.decisions += decisions
|
|
37
|
+
counts.input_tokens += input_tokens
|
|
38
|
+
|
|
39
|
+
def buckets(self, starting_on: datetime | None = None, ending_before: datetime | None = None) -> list[dict]:
|
|
40
|
+
"""Every bucket whose hour starts within the window, by hour then model; a naive bound is read as UTC."""
|
|
41
|
+
start, end = _epoch(starting_on), _epoch(ending_before)
|
|
42
|
+
with self._lock:
|
|
43
|
+
items = sorted(self._buckets.items())
|
|
44
|
+
return [{"hour": datetime.fromtimestamp(hour, UTC).isoformat(), "model": model,
|
|
45
|
+
"decisions": counts.decisions, "input_tokens": counts.input_tokens}
|
|
46
|
+
for (hour, model), counts in items
|
|
47
|
+
if (start is None or hour >= start) and (end is None or hour < end)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Metered:
|
|
51
|
+
"""An engine whose scoring passes add up their input tokens, so a batch that answers
|
|
52
|
+
through `ruling.evals` can report what it cost without the evaluation code counting."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, engine):
|
|
55
|
+
self.engine = engine
|
|
56
|
+
self.input_tokens = 0
|
|
57
|
+
|
|
58
|
+
def __getattr__(self, name: str):
|
|
59
|
+
return getattr(self.engine, name)
|
|
60
|
+
|
|
61
|
+
def score(self, state, questions):
|
|
62
|
+
scored = self.engine.score(state, questions)
|
|
63
|
+
self.input_tokens += scored.input_tokens
|
|
64
|
+
return scored
|
|
65
|
+
|
|
66
|
+
def evaluate(self, request):
|
|
67
|
+
response = self.engine.evaluate(request)
|
|
68
|
+
self.input_tokens += response.usage.input_tokens
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _epoch(moment: datetime | None) -> float | None:
|
|
73
|
+
if moment is None:
|
|
74
|
+
return None
|
|
75
|
+
return (moment if moment.tzinfo is not None else moment.replace(tzinfo=UTC)).timestamp()
|
ruling-0.2.0/ruling/sdk.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|