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.
Files changed (40) hide show
  1. {ruling-0.2.0 → ruling-0.3.0}/PKG-INFO +9 -6
  2. {ruling-0.2.0 → ruling-0.3.0}/README.md +6 -5
  3. {ruling-0.2.0 → ruling-0.3.0}/pyproject.toml +4 -1
  4. {ruling-0.2.0 → ruling-0.3.0}/ruling/__init__.py +4 -1
  5. {ruling-0.2.0 → ruling-0.3.0}/ruling/cli.py +15 -2
  6. {ruling-0.2.0 → ruling-0.3.0}/ruling/history.html +23 -1
  7. {ruling-0.2.0 → ruling-0.3.0}/ruling/questions.py +8 -1
  8. {ruling-0.2.0 → ruling-0.3.0}/ruling/retrain.py +35 -9
  9. ruling-0.3.0/ruling/sdk.py +177 -0
  10. {ruling-0.2.0 → ruling-0.3.0}/ruling/server.py +186 -25
  11. {ruling-0.2.0 → ruling-0.3.0}/ruling/train.py +46 -9
  12. ruling-0.3.0/ruling/usage.py +75 -0
  13. ruling-0.2.0/ruling/sdk.py +0 -52
  14. {ruling-0.2.0 → ruling-0.3.0}/.gitignore +0 -0
  15. {ruling-0.2.0 → ruling-0.3.0}/LICENSE +0 -0
  16. {ruling-0.2.0 → ruling-0.3.0}/ruling/against.py +0 -0
  17. {ruling-0.2.0 → ruling-0.3.0}/ruling/build.py +0 -0
  18. {ruling-0.2.0 → ruling-0.3.0}/ruling/calibration.py +0 -0
  19. {ruling-0.2.0 → ruling-0.3.0}/ruling/cascade.py +0 -0
  20. {ruling-0.2.0 → ruling-0.3.0}/ruling/codes.py +0 -0
  21. {ruling-0.2.0 → ruling-0.3.0}/ruling/config.py +0 -0
  22. {ruling-0.2.0 → ruling-0.3.0}/ruling/corpus.py +0 -0
  23. {ruling-0.2.0 → ruling-0.3.0}/ruling/decision.py +0 -0
  24. {ruling-0.2.0 → ruling-0.3.0}/ruling/decision_train.py +0 -0
  25. {ruling-0.2.0 → ruling-0.3.0}/ruling/distill.py +0 -0
  26. {ruling-0.2.0 → ruling-0.3.0}/ruling/engine.py +0 -0
  27. {ruling-0.2.0 → ruling-0.3.0}/ruling/evals.py +0 -0
  28. {ruling-0.2.0 → ruling-0.3.0}/ruling/failover.py +0 -0
  29. {ruling-0.2.0 → ruling-0.3.0}/ruling/hosted.py +0 -0
  30. {ruling-0.2.0 → ruling-0.3.0}/ruling/ledger.py +0 -0
  31. {ruling-0.2.0 → ruling-0.3.0}/ruling/metrics.py +0 -0
  32. {ruling-0.2.0 → ruling-0.3.0}/ruling/modernbert.py +0 -0
  33. {ruling-0.2.0 → ruling-0.3.0}/ruling/peft.py +0 -0
  34. {ruling-0.2.0 → ruling-0.3.0}/ruling/playground.html +0 -0
  35. {ruling-0.2.0 → ruling-0.3.0}/ruling/prompt.py +0 -0
  36. {ruling-0.2.0 → ruling-0.3.0}/ruling/registry.py +0 -0
  37. {ruling-0.2.0 → ruling-0.3.0}/ruling/states.py +0 -0
  38. {ruling-0.2.0 → ruling-0.3.0}/ruling/thinking.py +0 -0
  39. {ruling-0.2.0 → ruling-0.3.0}/ruling/vision.py +0 -0
  40. {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.2.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, Python 3.12+, and [uv](https://docs.astral.sh/uv/).
57
+ You need an Apple Silicon Mac and Python 3.12+.
56
58
 
57
59
  ```bash
58
- git clone https://github.com/bradAGI/ruling && cd ruling
59
- uv sync # add --extra vision to ask about images
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. On NVIDIA, see
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, Python 3.12+, and [uv](https://docs.astral.sh/uv/).
33
+ You need an Apple Silicon Mac and Python 3.12+.
34
34
 
35
35
  ```bash
36
- git clone https://github.com/bradAGI/ruling && cd ruling
37
- uv sync # add --extra vision to ask about images
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. On NVIDIA, see
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.2.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
- Trainer(config, resume=args.resume).run()
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><th>verdict</th></tr></thead><tbody>';
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: Literal["image/png", "image/jpeg", "image/webp"]
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. Every run leaves
10
- its records, its training log and the comparison in its version directory,
11
- promoted or not.
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
- scores = {name: decision_blocks(collect(engine, held_out), Calibration())["overall"]
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` points at.
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 = [json.loads((version / REPORT).read_text())
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 decision endpoint over a registry of models, model listing, health, and a playground page."""
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="0.2.0")
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
- try:
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
- if not admission.enter():
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
- except InputTooLong as exc:
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 / "training.json").write_text(json.dumps(meta, indent=2) + "\n")
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 = [{"step": 0, **self.validate()}]
440
- log(json.dumps(history[-1]))
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
- history.append(entry)
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 / "training.json").read_text())
512
+ meta = json.loads((config.output / TRAINING).read_text())
476
513
  meta["history"] = history
477
- (config.output / "training.json").write_text(json.dumps(meta, indent=2) + "\n")
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()
@@ -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