ruling 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ruling/server.py ADDED
@@ -0,0 +1,198 @@
1
+ """HTTP surface: one decision endpoint over a registry of models, model listing, health, and a playground page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from fastapi import Depends, FastAPI, Request
12
+ from fastapi.concurrency import run_in_threadpool
13
+ from fastapi.exceptions import RequestValidationError
14
+ from fastapi.responses import HTMLResponse, JSONResponse
15
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
16
+
17
+ from ruling.config import Settings
18
+ from ruling.engine import InputTooLong
19
+ from ruling.hosted import HostUnavailable
20
+ from ruling.ledger import DecisionLedger, Outcome, UnknownDecision
21
+ from ruling.questions import (
22
+ InvalidOptions,
23
+ ModelList,
24
+ SystemOneRequest,
25
+ SystemOneResponse,
26
+ UnsupportedModality,
27
+ check_askable,
28
+ check_readable,
29
+ )
30
+ from ruling.registry import Registry, UnknownModel
31
+ from ruling.retrain import CURRENT, history
32
+
33
+ log = logging.getLogger("ruling")
34
+ PLAYGROUND = Path(__file__).with_name("playground.html")
35
+ HISTORY = Path(__file__).with_name("history.html")
36
+
37
+ # Weight of the latest request in the running estimate of how long one takes, which
38
+ # sizes the Retry-After a turned-away client is given.
39
+ LATENCY_SMOOTHING = 0.2
40
+
41
+
42
+ class ApiError(Exception):
43
+ """An error in the hosted API's shape: a stable `code` for programs, a `message` for people."""
44
+
45
+ def __init__(self, status: int, code: str, message: str, param: str | None = None, retry_after: int | None = None):
46
+ super().__init__(message)
47
+ self.status, self.code, self.message, self.param, self.retry_after = status, code, message, param, retry_after
48
+
49
+ def response(self) -> JSONResponse:
50
+ error = {"code": self.code, "message": self.message}
51
+ if self.param is not None:
52
+ error["param"] = self.param
53
+ headers = {"Retry-After": str(self.retry_after)} if self.retry_after is not None else None
54
+ return JSONResponse({"error": error}, status_code=self.status, headers=headers)
55
+
56
+
57
+ class Admission:
58
+ """How many requests may be in flight at once, and how long a refused one should wait."""
59
+
60
+ def __init__(self, limit: int):
61
+ if limit < 1:
62
+ raise ValueError("at least one request must be admitted")
63
+ self.limit = limit
64
+ self.pending = 0
65
+ self.seconds_per_request: float | None = None
66
+ self._lock = threading.Lock()
67
+
68
+ def enter(self) -> bool:
69
+ with self._lock:
70
+ if self.pending >= self.limit:
71
+ return False
72
+ self.pending += 1
73
+ return True
74
+
75
+ def leave(self, elapsed: float) -> None:
76
+ with self._lock:
77
+ self.pending -= 1
78
+ if self.seconds_per_request is None:
79
+ self.seconds_per_request = elapsed
80
+ else:
81
+ self.seconds_per_request += LATENCY_SMOOTHING * (elapsed - self.seconds_per_request)
82
+
83
+ def retry_after(self) -> int:
84
+ """Whole seconds until the queue has likely drained, never less than one."""
85
+ with self._lock:
86
+ estimate = self.pending * (self.seconds_per_request or 0.0)
87
+ return max(1, math.ceil(estimate))
88
+
89
+
90
+ 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")
93
+ bearer = HTTPBearer(auto_error=False)
94
+ admission = Admission(max_pending)
95
+
96
+ def authorize(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)) -> None:
97
+ if api_key is None:
98
+ return
99
+ if credentials is None or credentials.credentials != api_key:
100
+ raise ApiError(401, "unauthorized", "invalid or missing bearer token")
101
+
102
+ @app.exception_handler(ApiError)
103
+ async def api_error(request: Request, exc: ApiError) -> JSONResponse:
104
+ return exc.response()
105
+
106
+ @app.exception_handler(RequestValidationError)
107
+ async def invalid_request(request: Request, exc: RequestValidationError) -> JSONResponse:
108
+ first, *rest = exc.errors()
109
+ param = ".".join(str(part) for part in first["loc"][1:]) or None
110
+ message = first["msg"] + (f" (and {len(rest)} more)" if rest else "")
111
+ return ApiError(422, "invalid_input", message, param).response()
112
+
113
+ @app.post("/v1/systemone", response_model=SystemOneResponse, response_model_exclude_none=True,
114
+ dependencies=[Depends(authorize)])
115
+ 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
120
+ try:
121
+ check_askable(request.questions, engine.max_options)
122
+ except InvalidOptions as exc:
123
+ raise ApiError(422, "invalid_options", str(exc), f"questions.{exc.question_id}") from exc
124
+ try:
125
+ check_readable(request.state, engine.reads_images)
126
+ except UnsupportedModality as exc:
127
+ 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:
133
+ 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)
146
+ log.info("systemone model=%s questions=%d input_tokens=%d ms=%.0f",
147
+ name, len(request.questions), response.usage.input_tokens, elapsed * 1000)
148
+ # An entry answers under its own name; the primary keeps the engine's report, which a
149
+ # failover changes to say which side answered.
150
+ if name != registry.default:
151
+ response = response.model_copy(update={"model": name})
152
+ if ledger is not None and not request.exclude_from_training:
153
+ response = response.model_copy(update={"id": await run_in_threadpool(ledger.record, request, response)})
154
+ return response
155
+
156
+ if ledger is not None:
157
+ @app.post("/v1/decisions/{decision}/outcome", dependencies=[Depends(authorize)])
158
+ async def outcome(decision: str, body: Outcome) -> dict:
159
+ try:
160
+ record = await run_in_threadpool(ledger.outcome, decision, body.labels)
161
+ except UnknownDecision as exc:
162
+ raise ApiError(404, "unknown_decision", str(exc)) from exc
163
+ except ValueError as exc:
164
+ raise ApiError(422, "invalid_input", str(exc), "labels") from exc
165
+ return {"id": decision, "labels": record.labels}
166
+
167
+ @app.get("/v1/models", response_model=ModelList, dependencies=[Depends(authorize)])
168
+ def models() -> ModelList:
169
+ return ModelList(models=registry.info())
170
+
171
+ @app.get("/v1/retrain", dependencies=[Depends(authorize)])
172
+ def retraining() -> dict:
173
+ """Each served model whose adapter is a `ruling retrain` directory's `current` link, with its versions."""
174
+ return {"models": [{"name": name, **history(engine.adapter_path.parent)}
175
+ for name, engine in registry.engines.items()
176
+ if getattr(engine, "adapter_path", None) is not None and engine.adapter_path.name == CURRENT]}
177
+
178
+ @app.get("/health")
179
+ def health() -> dict[str, str | None]:
180
+ engine = registry.engines[registry.default]
181
+ return {"status": "ok", "model": registry.default, "revision": engine.revision}
182
+
183
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
184
+ def playground(request: Request) -> str:
185
+ return PLAYGROUND.read_text()
186
+
187
+ @app.get("/history", response_class=HTMLResponse, include_in_schema=False)
188
+ def history_page() -> str:
189
+ return HISTORY.read_text()
190
+
191
+ return app
192
+
193
+
194
+ def app_from_env() -> FastAPI:
195
+ settings = Settings.from_env()
196
+ retain = settings.ledger_retain_days * 86_400 if settings.ledger_retain_days is not None else None
197
+ ledger = DecisionLedger(settings.ledger_path, retain) if settings.ledger_path else None
198
+ return create_app(Registry.from_settings(settings), settings.api_key, settings.max_pending, ledger)
ruling/states.py ADDED
@@ -0,0 +1,200 @@
1
+ """Synthetic program state with answers computed in code.
2
+
3
+ Each generator builds a JSON state, including any policy the question depends
4
+ on, and asks Choice, Score, and Noul questions whose answers a pure function
5
+ derives from that state. Labels are therefore exact. This is the part of the
6
+ training mix that teaches reading structured state rather than prose.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+
13
+ import numpy as np
14
+
15
+ from ruling.evals import Record
16
+
17
+ NAMES = ["Avery", "Blake", "Casey", "Devon", "Emery", "Finley", "Harper", "Jordan", "Kai", "Logan", "Morgan", "Quinn",
18
+ "Reese", "Rowan", "Sage", "Taylor"]
19
+ SERVICES = ["api", "web", "auth", "billing", "search", "jobs", "gateway", "notifications", "reports", "uploads"]
20
+ RESOURCES = ["payroll export", "customer database", "build server", "design drive", "analytics warehouse", "wiki",
21
+ "production logs", "finance ledger"]
22
+
23
+
24
+ # ---- orders -------------------------------------------------------------------
25
+
26
+ def orders_answers(state: dict, subject: str, threshold: float) -> dict:
27
+ today = dt.date.fromisoformat(state["today"])
28
+ orders = {o["id"]: o for o in state["orders"]}
29
+ order = orders[subject]
30
+ refundable = (order["status"] == "delivered"
31
+ and (today - dt.date.fromisoformat(order["delivered_on"])).days <= state["refund_policy_days"])
32
+ delivered_total = sum(o["amount_usd"] for o in state["orders"] if o["status"] == "delivered")
33
+ return {
34
+ "subject_refundable": refundable,
35
+ "largest": max(state["orders"], key=lambda o: o["amount_usd"])["id"],
36
+ "pending": sum(1 for o in state["orders"] if o["status"] == "pending"),
37
+ "delivered_total_over": delivered_total > threshold,
38
+ }
39
+
40
+
41
+ def _orders(rng: np.random.Generator) -> Record:
42
+ today = dt.date(2026, 1, 1) + dt.timedelta(days=int(rng.integers(0, 300)))
43
+ count = int(rng.integers(3, 8))
44
+ ids = [f"{chr(65 + int(rng.integers(26)))}{int(rng.integers(100, 999))}" for _ in range(count)]
45
+ ids = list(dict.fromkeys(ids))
46
+ orders = []
47
+ for oid in ids:
48
+ status = str(rng.choice(["pending", "shipped", "delivered", "delivered", "refunded", "cancelled"]))
49
+ delivered = today - dt.timedelta(days=int(rng.integers(0, 45))) if status in ("delivered", "refunded") else None
50
+ orders.append({"id": oid, "customer": str(rng.choice(NAMES)), "amount_usd": round(float(rng.uniform(5, 1500)), 2),
51
+ "status": status, "delivered_on": delivered.isoformat() if delivered else None})
52
+ amounts = [o["amount_usd"] for o in orders]
53
+ if len(set(amounts)) != len(amounts):
54
+ return _orders(rng)
55
+ state = {"today": today.isoformat(), "refund_policy_days": int(rng.choice([7, 14, 30])), "orders": orders}
56
+ subject = str(rng.choice(ids))
57
+ threshold = float(rng.choice([500, 1000, 2000, 3000]))
58
+ answers = orders_answers(state, subject, threshold)
59
+ return Record(
60
+ family="state_orders",
61
+ state=state,
62
+ questions={
63
+ "subject_refundable": {"type": "noul", "instructions": f"Under the refund policy, order {subject} can still be refunded today."},
64
+ "largest": {"type": "choice", "instructions": "Which order has the largest amount?", "criteria": {i: None for i in ids}},
65
+ "pending": {"type": "score", "instructions": "How many orders are still pending?",
66
+ "criteria": ["none", "one", "two", "three or more"]},
67
+ "delivered_total_over": {"type": "noul",
68
+ "instructions": f"The delivered orders add up to more than ${threshold:,.0f}."},
69
+ },
70
+ labels={**{k: answers[k] for k in ("subject_refundable", "largest", "delivered_total_over")},
71
+ "pending": min(answers["pending"], 3)},
72
+ )
73
+
74
+
75
+ # ---- services -----------------------------------------------------------------
76
+
77
+ def services_answers(state: dict, subject: str) -> dict:
78
+ limits = state["thresholds"]
79
+
80
+ def breach(s):
81
+ return s["error_rate"] > limits["error_rate"] or s["p95_ms"] > limits["p95_ms"]
82
+
83
+ services = {s["name"]: s for s in state["services"]}
84
+ return {
85
+ "subject_breach": breach(services[subject]),
86
+ "worst_error": max(state["services"], key=lambda s: s["error_rate"])["name"],
87
+ "breaches": sum(1 for s in state["services"] if breach(s)),
88
+ }
89
+
90
+
91
+ def _services(rng: np.random.Generator) -> Record:
92
+ names = list(rng.choice(SERVICES, size=int(rng.integers(3, 7)), replace=False))
93
+ services = [{"name": str(n), "error_rate": round(float(rng.choice([0.0, rng.uniform(0, 0.01), rng.uniform(0.01, 0.08)])), 4),
94
+ "p95_ms": int(rng.integers(40, 1600))} for n in names]
95
+ rates = [s["error_rate"] for s in services]
96
+ if len(set(rates)) != len(rates):
97
+ return _services(rng)
98
+ state = {"thresholds": {"error_rate": float(rng.choice([0.01, 0.02, 0.05])), "p95_ms": int(rng.choice([500, 800, 1200]))},
99
+ "services": services}
100
+ subject = str(rng.choice(names))
101
+ answers = services_answers(state, subject)
102
+ return Record(
103
+ family="state_services",
104
+ state=state,
105
+ questions={
106
+ "subject_breach": {"type": "noul", "instructions": f"The {subject} service is breaching at least one threshold."},
107
+ "worst_error": {"type": "choice", "instructions": "Which service has the highest error rate?",
108
+ "criteria": {str(n): None for n in names}},
109
+ "breaches": {"type": "score", "instructions": "How many services breach a threshold?",
110
+ "criteria": ["none", "one", "two", "three or more"]},
111
+ },
112
+ labels={"subject_breach": answers["subject_breach"], "worst_error": answers["worst_error"],
113
+ "breaches": min(answers["breaches"], 3)},
114
+ )
115
+
116
+
117
+ # ---- access requests ----------------------------------------------------------
118
+
119
+ ACCESS_POLICY = ("Internal resources: employees are permitted; contractors need manager approval. "
120
+ "Restricted resources: employees need both manager and security approval; contractors are always denied.")
121
+
122
+
123
+ def access_decision(role: str, classification: str, approvals: list[str]) -> str:
124
+ if classification == "internal":
125
+ return "permit" if role != "contractor" or "manager" in approvals else "needs_approval"
126
+ if role == "contractor":
127
+ return "deny"
128
+ return "permit" if {"manager", "security"} <= set(approvals) else "needs_approval"
129
+
130
+
131
+ def _access(rng: np.random.Generator) -> Record:
132
+ role = str(rng.choice(["engineer", "analyst", "contractor"]))
133
+ classification = str(rng.choice(["internal", "restricted"]))
134
+ approvals = [a for a in ("manager", "security") if rng.random() < 0.5]
135
+ state = {"policy": ACCESS_POLICY,
136
+ "request": {"requester": str(rng.choice(NAMES)), "role": role, "employment": "contractor" if role == "contractor" else "employee",
137
+ "resource": str(rng.choice(RESOURCES)), "classification": classification, "approvals": approvals}}
138
+ return Record(
139
+ family="state_access",
140
+ state=state,
141
+ questions={
142
+ "decision": {"type": "choice", "instructions": "What does the policy say to do with this access request?",
143
+ "criteria": {"permit": "Grant access now", "needs_approval": "Hold until the missing approval arrives",
144
+ "deny": "Refuse the request"}},
145
+ "has_security": {"type": "noul", "instructions": "Security has approved this request."},
146
+ },
147
+ labels={"decision": access_decision(role, classification, approvals), "has_security": "security" in approvals},
148
+ )
149
+
150
+
151
+ # ---- inventory ----------------------------------------------------------------
152
+
153
+ COVER_LEVELS = ["under a week", "one to two weeks", "two to four weeks", "more than four weeks"]
154
+
155
+
156
+ def _cover_level(days: float) -> int:
157
+ return 0 if days < 7 else 1 if days < 14 else 2 if days < 28 else 3
158
+
159
+
160
+ def inventory_answers(state: dict, subject: str) -> dict:
161
+ items = {i["sku"]: i for i in state["items"]}
162
+ item = items[subject]
163
+ return {
164
+ "subject_reorder": item["on_hand"] <= item["reorder_point"],
165
+ "lowest_cover": min(state["items"], key=lambda i: i["on_hand"] / i["daily_sales"])["sku"],
166
+ "subject_cover_level": _cover_level(item["on_hand"] / item["daily_sales"]),
167
+ }
168
+
169
+
170
+ def _inventory(rng: np.random.Generator) -> Record:
171
+ skus = list(dict.fromkeys(f"SKU-{int(rng.integers(1000, 9999))}" for _ in range(int(rng.integers(3, 7)))))
172
+ items = [{"sku": s, "on_hand": int(rng.integers(0, 600)), "daily_sales": int(rng.integers(1, 40)),
173
+ "reorder_point": int(rng.integers(20, 200))} for s in skus]
174
+ covers = [i["on_hand"] / i["daily_sales"] for i in items]
175
+ if len(set(covers)) != len(covers):
176
+ return _inventory(rng)
177
+ state = {"items": items}
178
+ subject = str(rng.choice(skus))
179
+ answers = inventory_answers(state, subject)
180
+ return Record(
181
+ family="state_inventory",
182
+ state=state,
183
+ questions={
184
+ "subject_reorder": {"type": "noul", "instructions": f"{subject} is at or below its reorder point."},
185
+ "lowest_cover": {"type": "choice", "instructions": "Which item will run out soonest at its current daily sales?",
186
+ "criteria": {s: None for s in skus}},
187
+ "subject_cover_level": {"type": "score", "instructions": f"How long will current stock of {subject} last?",
188
+ "criteria": COVER_LEVELS},
189
+ },
190
+ labels=answers,
191
+ )
192
+
193
+
194
+ GENERATORS = {"orders": _orders, "services": _services, "access": _access, "inventory": _inventory}
195
+
196
+
197
+ def generate(count: int, seed: int) -> list[Record]:
198
+ rng = np.random.default_rng(seed)
199
+ makers = list(GENERATORS.values())
200
+ return [makers[i % len(makers)](rng) for i in range(count)]
ruling/thinking.py ADDED
@@ -0,0 +1,75 @@
1
+ """A teacher that reasons before it answers.
2
+
3
+ For each question the model writes its reasoning, the reasoning is closed
4
+ after `</think>`, and the answer is then read from the restricted letter
5
+ logits exactly as the fast engine reads it. The result is a calibrated
6
+ distribution informed by slow reasoning: the System Two answers a System One
7
+ model is trained to reproduce. It is far too slow to serve.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import mlx.core as mx
13
+ import numpy as np
14
+ from mlx_lm import batch_generate
15
+ from mlx_lm.sample_utils import make_sampler
16
+
17
+ from ruling.calibration import Provenance, log_softmax
18
+ from ruling.engine import Engine, InputTooLong, RawScore, Scored, answer, option_logits
19
+ from ruling.prompt import render_question
20
+ from ruling.questions import Choice, Noul, Score, State, SystemOneRequest, SystemOneResponse, Usage, option_keys
21
+
22
+ THINK_END = "\n</think>\n\n"
23
+
24
+
25
+ class ThinkingEngine:
26
+ def __init__(self, engine: Engine, max_think_tokens: int):
27
+ self.engine = engine
28
+ self.max_think_tokens = max_think_tokens
29
+ self.model_id = f"{engine.model_id} thinking {max_think_tokens}"
30
+ self.revision = engine.revision
31
+ self.adapter = engine.adapter
32
+ self.rotations = 1
33
+ self.prior_debias = False
34
+ self.max_options = engine.codes.max_options
35
+ self.reads_images = engine.reads_images
36
+ self.max_input_tokens = engine.max_input_tokens
37
+ self.calibration = engine.calibration
38
+
39
+ @property
40
+ def provenance(self) -> Provenance:
41
+ return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
42
+
43
+ def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
44
+ engine = self.engine
45
+ prefix, tail = engine.split_prompt(state, thinking=True)
46
+ prefix_tokens = engine._encode(prefix)
47
+ rendered = {qid: render_question(q, engine.codes.codes, 0) for qid, q in questions.items()}
48
+ if any(len(r.keys) > engine.codes.max_options for r in rendered.values()):
49
+ raise InputTooLong("a question has more options than the teacher's answer codes")
50
+ prompts = [prefix_tokens + engine._encode(r.text + tail) for r in rendered.values()]
51
+ if max(len(p) for p in prompts) + self.max_think_tokens > self.max_input_tokens:
52
+ raise InputTooLong(f"request needs more than {self.max_input_tokens} tokens with reasoning")
53
+ texts = batch_generate(engine.model, engine.tokenizer, prompts, max_tokens=self.max_think_tokens,
54
+ sampler=make_sampler(temp=0.0)).texts
55
+ sequences = [p + engine._encode(t.split("</think>")[0].rstrip() + THINK_END) for p, t in zip(prompts, texts)]
56
+ width = max(len(s) for s in sequences)
57
+ tokens = mx.array([s + [engine._pad_id] * (width - len(s)) for s in sequences])
58
+ last = mx.array([len(s) - 1 for s in sequences])
59
+ cardinality = max(len(r.keys) for r in rendered.values())
60
+ ids = mx.array([engine.codes.token_ids[:cardinality] for _ in sequences])
61
+ logits = np.array(option_logits(engine.model, tokens, None, last, ids), dtype=np.float64)
62
+ raw = {}
63
+ for row, (qid, question) in zip(logits, questions.items()):
64
+ keys = rendered[qid].keys
65
+ log_probs = dict(zip(keys, log_softmax(row[: len(keys)])))
66
+ raw[qid] = RawScore(keys=option_keys(question), logits=np.array([log_probs[k] for k in option_keys(question)]))
67
+ return Scored(raw=raw, input_tokens=sum(len(s) for s in sequences))
68
+
69
+ def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
70
+ scored = self.score(request.state, request.questions)
71
+ answers = {qid: answer(request.questions[qid], scored.raw[qid], self.calibration) for qid in request.questions}
72
+ return SystemOneResponse(model=self.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens))
73
+
74
+ def answer(self, question, raw: RawScore):
75
+ return answer(question, raw, self.calibration)