ruling 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ruling/__init__.py +25 -0
- ruling/against.py +50 -0
- ruling/build.py +257 -0
- ruling/calibration.py +182 -0
- ruling/cascade.py +83 -0
- ruling/cli.py +397 -0
- ruling/codes.py +30 -0
- ruling/config.py +120 -0
- ruling/corpus.py +202 -0
- ruling/decision.py +246 -0
- ruling/decision_train.py +208 -0
- ruling/distill.py +131 -0
- ruling/engine.py +518 -0
- ruling/evals.py +459 -0
- ruling/failover.py +62 -0
- ruling/history.html +78 -0
- ruling/hosted.py +394 -0
- ruling/ledger.py +120 -0
- ruling/metrics.py +66 -0
- ruling/modernbert.py +163 -0
- ruling/peft.py +144 -0
- ruling/playground.html +217 -0
- ruling/prompt.py +134 -0
- ruling/questions.py +280 -0
- ruling/registry.py +195 -0
- ruling/retrain.py +125 -0
- ruling/sdk.py +52 -0
- ruling/server.py +198 -0
- ruling/states.py +200 -0
- ruling/thinking.py +75 -0
- ruling/train.py +481 -0
- ruling/vision.py +100 -0
- ruling/worlds.py +134 -0
- ruling-0.2.0.dist-info/METADATA +83 -0
- ruling-0.2.0.dist-info/RECORD +38 -0
- ruling-0.2.0.dist-info/WHEEL +4 -0
- ruling-0.2.0.dist-info/entry_points.txt +2 -0
- ruling-0.2.0.dist-info/licenses/LICENSE +21 -0
ruling/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)
|