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/distill.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Distillation data: a large open model writes and answers typed questions about many states.
|
|
2
|
+
|
|
3
|
+
The served model is the fast decision encoder, which generalizes poorly when
|
|
4
|
+
trained only on a few labeled sources. Here a large open-weight decoder writes
|
|
5
|
+
varied Choice, Score, and Noul questions about each state, then answers them
|
|
6
|
+
through the decoder engine with option-order averaging. Its probabilities
|
|
7
|
+
become soft targets, so the encoder learns the larger model's judgment,
|
|
8
|
+
uncertainty included, over far more kinds of question than any labeled
|
|
9
|
+
dataset holds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import mlx.core as mx
|
|
19
|
+
from mlx_lm import batch_generate
|
|
20
|
+
from mlx_lm.sample_utils import make_sampler
|
|
21
|
+
from pydantic import TypeAdapter, ValidationError
|
|
22
|
+
|
|
23
|
+
from ruling.calibration import softmax
|
|
24
|
+
from ruling.engine import TEMPLATE_KWARGS, Engine, InputTooLong
|
|
25
|
+
from ruling.evals import Record, load_dataset
|
|
26
|
+
from ruling.questions import InvalidOptions, Question, State, check_askable
|
|
27
|
+
|
|
28
|
+
WRITER_SYSTEM = ("You design evaluation questions for software that makes fast judgments about documents. "
|
|
29
|
+
"Reply with one JSON object and nothing else.")
|
|
30
|
+
QUESTION = TypeAdapter(Question)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def writer_messages(state: State, count: int) -> list[dict[str, str]]:
|
|
34
|
+
body = state if isinstance(state, str) else json.dumps(state, indent=2, ensure_ascii=False)
|
|
35
|
+
instructions = f"""Document:
|
|
36
|
+
{body}
|
|
37
|
+
|
|
38
|
+
Write {count} different questions a program might need answered about this document. Mix three types:
|
|
39
|
+
- "noul": a statement that is true or false about the document.
|
|
40
|
+
- "choice": pick one of 2 to 8 named options; give each option a short description.
|
|
41
|
+
- "score": an ordered scale of 3 to 5 levels from lowest to highest.
|
|
42
|
+
Cover different aspects: intent, tone, facts stated, facts not stated, risk, next action, category. Include some questions
|
|
43
|
+
whose answer is genuinely uncertain from the document, and some whose answer is clearly no. Use snake_case ids.
|
|
44
|
+
|
|
45
|
+
Reply with exactly this JSON shape:
|
|
46
|
+
{{"questions": {{
|
|
47
|
+
"some_id": {{"type": "noul", "instructions": "..."}},
|
|
48
|
+
"other_id": {{"type": "choice", "instructions": "...", "criteria": {{"option_a": "description", "option_b": "description"}}}},
|
|
49
|
+
"third_id": {{"type": "score", "instructions": "...", "criteria": ["lowest level", "middle level", "highest level"]}}
|
|
50
|
+
}}}}"""
|
|
51
|
+
return [{"role": "system", "content": WRITER_SYSTEM}, {"role": "user", "content": instructions}]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_questions(text: str, max_options: int) -> dict:
|
|
55
|
+
"""The valid questions in a writer reply; anything malformed is dropped rather than repaired."""
|
|
56
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
|
|
57
|
+
start, end = text.find("{"), text.rfind("}")
|
|
58
|
+
if start < 0 or end <= start:
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
payload = json.loads(text[start:end + 1])
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return {}
|
|
64
|
+
candidates = payload.get("questions") if isinstance(payload, dict) else None
|
|
65
|
+
if not isinstance(candidates, dict):
|
|
66
|
+
return {}
|
|
67
|
+
questions = {}
|
|
68
|
+
for qid, spec in candidates.items():
|
|
69
|
+
try:
|
|
70
|
+
question = QUESTION.validate_python(spec)
|
|
71
|
+
check_askable({str(qid): question}, max_options)
|
|
72
|
+
except (ValidationError, InvalidOptions):
|
|
73
|
+
continue
|
|
74
|
+
questions[str(qid)] = question
|
|
75
|
+
return questions
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_questions(states: list[tuple[str, State]], generate, questions_per_state: int, batch_size: int,
|
|
79
|
+
max_options: int, log=print):
|
|
80
|
+
"""Yield, batch by batch, records carrying the questions a writer model produced for each state.
|
|
81
|
+
|
|
82
|
+
`generate` takes a list of chat message lists and returns one reply text per prompt.
|
|
83
|
+
"""
|
|
84
|
+
kept = 0
|
|
85
|
+
for start in range(0, len(states), batch_size):
|
|
86
|
+
batch = states[start:start + batch_size]
|
|
87
|
+
texts = generate([writer_messages(state, questions_per_state) for _, state in batch])
|
|
88
|
+
records = [Record(family=f"distill_{source}", state=state, questions=questions)
|
|
89
|
+
for (source, state), text in zip(batch, texts)
|
|
90
|
+
if (questions := parse_questions(text, max_options))]
|
|
91
|
+
kept += len(records)
|
|
92
|
+
log(f"{min(start + batch_size, len(states))}/{len(states)} states, {kept} with questions")
|
|
93
|
+
yield records
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def local_generator(engine: Engine, seed: int, temperature: float):
|
|
97
|
+
"""A `generate` function backed by a local MLX model with batched sampling."""
|
|
98
|
+
mx.random.seed(seed)
|
|
99
|
+
sampler = make_sampler(temp=temperature, top_p=0.95)
|
|
100
|
+
|
|
101
|
+
def generate(prompts: list[list[dict]]) -> list[str]:
|
|
102
|
+
tokens = [engine.tokenizer.apply_chat_template(messages, add_generation_prompt=True, **TEMPLATE_KWARGS) for messages in prompts]
|
|
103
|
+
return batch_generate(engine.model, engine.tokenizer, tokens, max_tokens=1200, sampler=sampler).texts
|
|
104
|
+
|
|
105
|
+
return generate
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def unanswered(records: list[Record], written: Path) -> list[Record]:
|
|
109
|
+
"""The records whose answers are not yet in `written`, matched by state and questions.
|
|
110
|
+
|
|
111
|
+
A record too long to answer leaves no line, so counting lines would misplace
|
|
112
|
+
the resume point and answer some records twice.
|
|
113
|
+
"""
|
|
114
|
+
if not written.exists():
|
|
115
|
+
return records
|
|
116
|
+
key = lambda r: json.dumps([r.state, {qid: q.model_dump() for qid, q in r.questions.items()}], sort_keys=True)
|
|
117
|
+
done = {key(r) for r in load_dataset(written)}
|
|
118
|
+
return [r for r in records if key(r) not in done]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def answer_locally(records: list[Record], engine, log=print):
|
|
122
|
+
"""Yield each record with the engine's answer distributions as `target` references; records too long are skipped."""
|
|
123
|
+
for index, record in enumerate(records):
|
|
124
|
+
try:
|
|
125
|
+
scored = engine.score(record.state, record.questions)
|
|
126
|
+
except InputTooLong:
|
|
127
|
+
continue
|
|
128
|
+
targets = {qid: dict(zip(raw.keys, (float(p) for p in softmax(raw.logits)))) for qid, raw in scored.raw.items()}
|
|
129
|
+
if (index + 1) % 100 == 0:
|
|
130
|
+
log(f"{index + 1}/{len(records)} answered")
|
|
131
|
+
yield record.model_copy(update={"references": {qid: {"target": t} for qid, t in targets.items()}})
|
ruling/engine.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Score typed questions against a state with a local MLX model.
|
|
2
|
+
|
|
3
|
+
No text is generated. The state prefix is encoded once into a KV cache. Every
|
|
4
|
+
question suffix (one per rotation of its answer order) is then scored in
|
|
5
|
+
batched forward passes against replicas of that cache, and the logits at each
|
|
6
|
+
suffix's answer position are restricted to the allowed answer codes.
|
|
7
|
+
|
|
8
|
+
Positional bias is removed two ways: a content-free letter prior measured once
|
|
9
|
+
per answer count is subtracted from every log-probability, and the remaining
|
|
10
|
+
orderings are averaged in log space.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import date
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import mlx.core as mx
|
|
23
|
+
import numpy as np
|
|
24
|
+
from huggingface_hub import HfApi, snapshot_download
|
|
25
|
+
from huggingface_hub.errors import HfHubHTTPError, HFValidationError, LocalEntryNotFoundError
|
|
26
|
+
from mlx.utils import tree_flatten
|
|
27
|
+
from mlx_lm import load
|
|
28
|
+
from mlx_lm.models.cache import make_prompt_cache
|
|
29
|
+
from mlx_lm.tuner.utils import linear_to_lora_layers
|
|
30
|
+
|
|
31
|
+
from ruling.calibration import Calibration, Provenance, choice_confidence, log_softmax, score_confidence, softmax
|
|
32
|
+
from ruling.codes import AnswerCodes
|
|
33
|
+
from ruling.prompt import (
|
|
34
|
+
PRIOR_STATE,
|
|
35
|
+
SPLIT_MARKER,
|
|
36
|
+
SYSTEM_PROMPT,
|
|
37
|
+
prior_question,
|
|
38
|
+
render_question,
|
|
39
|
+
render_state,
|
|
40
|
+
)
|
|
41
|
+
from ruling.questions import (
|
|
42
|
+
MAX_CHOICE_OPTIONS,
|
|
43
|
+
Choice,
|
|
44
|
+
ChoiceAnswer,
|
|
45
|
+
Noul,
|
|
46
|
+
NoulAnswer,
|
|
47
|
+
Score,
|
|
48
|
+
ScoreAnswer,
|
|
49
|
+
State,
|
|
50
|
+
SystemOneRequest,
|
|
51
|
+
SystemOneResponse,
|
|
52
|
+
Usage,
|
|
53
|
+
option_keys,
|
|
54
|
+
render_text,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Prefix tokens are pushed through the model in chunks so the full-vocabulary
|
|
58
|
+
# logits for a long state never have to exist all at once.
|
|
59
|
+
PREFILL_STEP = 512
|
|
60
|
+
|
|
61
|
+
# Suffix passes are batched until batch_size * (prefix + padded suffix) tokens
|
|
62
|
+
# would exceed this budget, which bounds the memory of the replicated cache.
|
|
63
|
+
BATCH_TOKEN_BUDGET = 65_536
|
|
64
|
+
|
|
65
|
+
# For a Choice with more options than single-token codes, every option is first
|
|
66
|
+
# scored on its own as a Noul and the strongest few go to a final Choice.
|
|
67
|
+
STAGE_TWO_CANDIDATES = 8
|
|
68
|
+
|
|
69
|
+
# Qwen3-family templates emit an empty thinking block when thinking is off.
|
|
70
|
+
# Templates without the variable ignore it.
|
|
71
|
+
TEMPLATE_KWARGS = {"enable_thinking": False}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class InputTooLong(ValueError):
|
|
75
|
+
"""Raised with a message that names the maximum context length, the phrase clients
|
|
76
|
+
such as the Decision Index harness look for to classify a rejection as a capacity
|
|
77
|
+
limit rather than a failure. `question_id` names the question at fault when one is."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, question_id: str | None = None):
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.question_id = question_id
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class RawScore:
|
|
86
|
+
"""Debiased log-probabilities over `keys`, in canonical key order, before calibration."""
|
|
87
|
+
|
|
88
|
+
keys: list[str]
|
|
89
|
+
logits: np.ndarray
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Scored:
|
|
94
|
+
raw: dict[str, RawScore]
|
|
95
|
+
input_tokens: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class _Pass:
|
|
100
|
+
question_id: str
|
|
101
|
+
keys: list[str]
|
|
102
|
+
tokens: list[int]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class _Prefix:
|
|
107
|
+
"""The tokens through the state, the template tail every question ends with, and what a
|
|
108
|
+
vision model needs beside them: its processor inputs for the images in the state, and how
|
|
109
|
+
far its position ids sit below the token count once the images are read."""
|
|
110
|
+
tokens: list[int]
|
|
111
|
+
tail: str
|
|
112
|
+
vision_inputs: dict | None = None
|
|
113
|
+
position_delta: int = 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TwoStageChoice:
|
|
117
|
+
"""Template method for engines that read at most `pass_options` options in one pass.
|
|
118
|
+
|
|
119
|
+
A Choice wider than that is first scored as a Noul per option; the strongest
|
|
120
|
+
few go to a final Choice, and every other option gets zero probability.
|
|
121
|
+
Subclasses supply `_score_plain` and `pass_options`.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
pass_options: int
|
|
125
|
+
|
|
126
|
+
def _score_plain(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
127
|
+
raise NotImplementedError
|
|
128
|
+
|
|
129
|
+
def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
130
|
+
large = {qid: q for qid, q in questions.items()
|
|
131
|
+
if isinstance(q, Choice) and len(q.criteria) > self.pass_options}
|
|
132
|
+
tokens = 0
|
|
133
|
+
reduced = dict(questions)
|
|
134
|
+
finalists: dict[str, list[str]] = {}
|
|
135
|
+
if large:
|
|
136
|
+
stage_one = self._score_plain(state, {
|
|
137
|
+
f"{qid}\x00{key}": Noul(instructions={"question": q.instructions, "candidate": {key: q.criteria[key]}})
|
|
138
|
+
for qid, q in large.items() for key in q.criteria
|
|
139
|
+
})
|
|
140
|
+
tokens += stage_one.input_tokens
|
|
141
|
+
for qid, q in large.items():
|
|
142
|
+
ranked = sorted(q.criteria, key=lambda key: -stage_one.raw[f"{qid}\x00{key}"].logits[0])
|
|
143
|
+
finalists[qid] = ranked[:STAGE_TWO_CANDIDATES]
|
|
144
|
+
reduced[qid] = Choice(instructions=q.instructions, criteria={k: q.criteria[k] for k in finalists[qid]})
|
|
145
|
+
scored = self._score_plain(state, reduced)
|
|
146
|
+
tokens += scored.input_tokens
|
|
147
|
+
raw = dict(scored.raw)
|
|
148
|
+
for qid, keys in finalists.items():
|
|
149
|
+
full = np.full(len(large[qid].criteria), -np.inf)
|
|
150
|
+
for key, value in zip(keys, scored.raw[qid].logits):
|
|
151
|
+
full[list(large[qid].criteria).index(key)] = value
|
|
152
|
+
raw[qid] = RawScore(keys=option_keys(large[qid]), logits=full)
|
|
153
|
+
return Scored(raw=raw, input_tokens=tokens)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def answer(question: Choice | Score | Noul, raw: RawScore, calibration: Calibration) -> ChoiceAnswer | ScoreAnswer | NoulAnswer:
|
|
157
|
+
"""A typed answer from calibrated probabilities over the question's canonical keys."""
|
|
158
|
+
probs = softmax(raw.logits, calibration.temperature(question.type))
|
|
159
|
+
distribution = {key: float(p) for key, p in zip(raw.keys, probs)}
|
|
160
|
+
if isinstance(question, Choice):
|
|
161
|
+
return ChoiceAnswer(choice=raw.keys[int(probs.argmax())], probabilities=distribution,
|
|
162
|
+
confidence=choice_confidence(probs))
|
|
163
|
+
if isinstance(question, Score):
|
|
164
|
+
return ScoreAnswer(
|
|
165
|
+
score=float((probs * np.arange(len(probs))).sum()),
|
|
166
|
+
legend={str(i): render_text(level) for i, level in enumerate(question.criteria)},
|
|
167
|
+
probabilities=distribution,
|
|
168
|
+
confidence=score_confidence(probs),
|
|
169
|
+
)
|
|
170
|
+
return NoulAnswer(noul=distribution["yes"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def option_logits(model, tokens: mx.array, cache, last: mx.array, option_ids: mx.array,
|
|
174
|
+
position_ids: mx.array | None = None, inputs_embeds: mx.array | None = None) -> mx.array:
|
|
175
|
+
"""Logits for each row's answer codes at its last real position, shape [batch, options].
|
|
176
|
+
|
|
177
|
+
Only the hidden state at the answer position goes through the output head,
|
|
178
|
+
so a batch never materializes full-vocabulary logits for every token. This
|
|
179
|
+
follows the mlx-lm causal layout: an optional `language_model` wrapper, a
|
|
180
|
+
`.model` that returns final hidden states, and either a separate `lm_head`
|
|
181
|
+
or tied input embeddings.
|
|
182
|
+
"""
|
|
183
|
+
text = getattr(model, "language_model", model)
|
|
184
|
+
# Embeddings with image features and the positions images moved are passed only when there are images;
|
|
185
|
+
# text-only models take neither.
|
|
186
|
+
extra = {key: value for key, value in (("position_ids", position_ids), ("inputs_embeds", inputs_embeds))
|
|
187
|
+
if value is not None}
|
|
188
|
+
hidden = text.model(tokens, cache=cache, **extra)
|
|
189
|
+
picked = hidden[mx.arange(tokens.shape[0]), last]
|
|
190
|
+
tied = getattr(text, "tie_word_embeddings", None)
|
|
191
|
+
if tied is None:
|
|
192
|
+
tied = text.args.tie_word_embeddings
|
|
193
|
+
logits = text.model.embed_tokens.as_linear(picked) if tied else text.lm_head(picked)
|
|
194
|
+
return mx.take_along_axis(logits, option_ids, axis=-1).astype(mx.float32)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def adapter_digest(adapter: Path | None) -> str | None:
|
|
198
|
+
"""A short content hash of LoRA weights, so calibration and reports name the exact adapter."""
|
|
199
|
+
if adapter is None:
|
|
200
|
+
return None
|
|
201
|
+
return hashlib.sha256((adapter / ADAPTER_WEIGHTS).read_bytes()).hexdigest()[:16]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
ADAPTER_WEIGHTS = "adapters.safetensors"
|
|
205
|
+
ADAPTER_CONFIG = "adapter_config.json"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def lora_parameters(model) -> dict[str, mx.array]:
|
|
209
|
+
"""Every LoRA weight in the model, keyed as mlx-lm saves and loads them."""
|
|
210
|
+
return {key: value for key, value in tree_flatten(model.parameters()) if key.rsplit(".", 1)[-1] in ("lora_a", "lora_b")}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _SharedModel:
|
|
214
|
+
"""What engines built on one loaded model have in common: the lock that serializes the GPU,
|
|
215
|
+
the LoRA shape its layers were converted to, and which engine's weights are in them now."""
|
|
216
|
+
|
|
217
|
+
def __init__(self):
|
|
218
|
+
self.lock = threading.Lock()
|
|
219
|
+
self.lora: dict | None = None
|
|
220
|
+
self.active: "Engine | None" = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def model_released(model_id: str) -> str:
|
|
224
|
+
"""The date the served weights were published, as `YYYY-MM-DD`, or "" when unknown.
|
|
225
|
+
|
|
226
|
+
Read from the Hugging Face repository when the model came from one and it is
|
|
227
|
+
reachable. A local directory, or an unreachable repository, falls back to the
|
|
228
|
+
date the weights landed on this machine. A served id carries its engine's
|
|
229
|
+
prefix (`openai:`, `vision:`, ...), which no repository id can contain, so the
|
|
230
|
+
repository is what follows the last colon. A remote service's own model names
|
|
231
|
+
(`typesafe:jev-latest`) name no repository: their date is unknown, and the
|
|
232
|
+
hosted API's clients require a string.
|
|
233
|
+
"""
|
|
234
|
+
repo = model_id.rsplit(":", 1)[-1]
|
|
235
|
+
local = Path(repo)
|
|
236
|
+
if not local.exists():
|
|
237
|
+
try:
|
|
238
|
+
return HfApi().model_info(repo).last_modified.date().isoformat()
|
|
239
|
+
except (HfHubHTTPError, HFValidationError, OSError, AttributeError):
|
|
240
|
+
try:
|
|
241
|
+
local = Path(snapshot_download(repo, allow_patterns=["config.json"], local_files_only=True))
|
|
242
|
+
except (HFValidationError, LocalEntryNotFoundError):
|
|
243
|
+
return ""
|
|
244
|
+
return date.fromtimestamp(local.stat().st_mtime).isoformat()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def model_revision(model_id: str) -> str | None:
|
|
248
|
+
"""The Hugging Face commit the weights came from, or None for a local directory."""
|
|
249
|
+
if Path(model_id).exists():
|
|
250
|
+
return None
|
|
251
|
+
# mlx_lm.load has already fetched the weights; only the cached config is consulted here.
|
|
252
|
+
path = Path(snapshot_download(model_id, allow_patterns=["config.json"], local_files_only=True))
|
|
253
|
+
return path.name if path.parent.name == "snapshots" else None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Engine(TwoStageChoice):
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
model_id: str,
|
|
260
|
+
calibration: Calibration,
|
|
261
|
+
max_input_tokens: int,
|
|
262
|
+
rotations: int = 1,
|
|
263
|
+
prior_debias: bool = True,
|
|
264
|
+
adapter: Path | None = None,
|
|
265
|
+
max_branch_tokens: int | None = None,
|
|
266
|
+
):
|
|
267
|
+
if rotations < 1:
|
|
268
|
+
raise ValueError("rotations must be at least 1")
|
|
269
|
+
self.model_id = model_id
|
|
270
|
+
self.max_input_tokens = max_input_tokens
|
|
271
|
+
self.max_branch_tokens = max_branch_tokens or max_input_tokens
|
|
272
|
+
self.rotations = rotations
|
|
273
|
+
self.prior_debias = prior_debias
|
|
274
|
+
self.adapter = adapter_digest(adapter)
|
|
275
|
+
self.model, self.tokenizer = self._load(model_id, adapter)
|
|
276
|
+
# Adapter weights come back lazy, and a request thread has no CPU stream to realize them
|
|
277
|
+
# on; settle every parameter here, on the thread that loaded the model.
|
|
278
|
+
mx.eval(self.model.parameters())
|
|
279
|
+
self.revision = model_revision(model_id)
|
|
280
|
+
self.codes = AnswerCodes.from_tokenizer(self.tokenizer)
|
|
281
|
+
self._pad_id = self.tokenizer.pad_token_id
|
|
282
|
+
if self._pad_id is None:
|
|
283
|
+
self._pad_id = self.tokenizer.eos_token_id
|
|
284
|
+
self._system_role = self._template_accepts_system_role()
|
|
285
|
+
self.max_options = MAX_CHOICE_OPTIONS
|
|
286
|
+
self.reads_images = False
|
|
287
|
+
self._shared = _SharedModel()
|
|
288
|
+
self._shared.lora = json.loads((adapter / ADAPTER_CONFIG).read_text()) if adapter else None
|
|
289
|
+
self._shared.active = self
|
|
290
|
+
self._lock = self._shared.lock
|
|
291
|
+
# The weights to put into the shared LoRA layers before answering. None until another
|
|
292
|
+
# engine shares this model, since until then nothing can have swapped them out.
|
|
293
|
+
self._lora_weights: dict[str, mx.array] | None = None
|
|
294
|
+
self.adapter_path = adapter
|
|
295
|
+
self._priors: dict[int, np.ndarray] = {}
|
|
296
|
+
calibration.check(self.provenance)
|
|
297
|
+
self.calibration = calibration
|
|
298
|
+
|
|
299
|
+
def _load(self, model_id: str, adapter: Path | None):
|
|
300
|
+
return load(model_id, adapter_path=str(adapter) if adapter else None)
|
|
301
|
+
|
|
302
|
+
class IncompatibleAdapter(ValueError):
|
|
303
|
+
"""The adapter's LoRA layout differs from the one the shared model was converted to."""
|
|
304
|
+
|
|
305
|
+
def variant(self, adapter: Path | None, calibration: Calibration) -> "Engine":
|
|
306
|
+
"""Another engine on this loaded model with its own adapter (or none) and calibration.
|
|
307
|
+
|
|
308
|
+
The first variant converts the shared model's layers to LoRA in the adapter's
|
|
309
|
+
layout; every later adapter must match that layout exactly, since mlx-lm can
|
|
310
|
+
only load weights into layers of the same rank and depth. Weights are read and
|
|
311
|
+
realized here, on the loading thread, and merely rebound per request.
|
|
312
|
+
"""
|
|
313
|
+
config = json.loads((adapter / ADAPTER_CONFIG).read_text()) if adapter else None
|
|
314
|
+
shared = self._shared
|
|
315
|
+
if shared.lora is None and config is not None:
|
|
316
|
+
linear_to_lora_layers(self.model, config["num_layers"], config["lora_parameters"])
|
|
317
|
+
shared.lora = config
|
|
318
|
+
# Fresh LoRA layers have zero `lora_b`, so these weights reproduce the plain base.
|
|
319
|
+
self._lora_weights = lora_parameters(self.model)
|
|
320
|
+
mx.eval(list(self._lora_weights.values()))
|
|
321
|
+
elif config is not None and config != shared.lora:
|
|
322
|
+
raise Engine.IncompatibleAdapter(
|
|
323
|
+
f"{adapter} has LoRA layout {config}, but the loaded {self.model_id} was converted for {shared.lora}")
|
|
324
|
+
if self._lora_weights is None and shared.lora is not None:
|
|
325
|
+
self._lora_weights = mx.load(str(self.adapter_path / ADAPTER_WEIGHTS))
|
|
326
|
+
mx.eval(list(self._lora_weights.values()))
|
|
327
|
+
other = type(self).__new__(type(self))
|
|
328
|
+
other.__dict__.update(self.__dict__)
|
|
329
|
+
other._priors = {}
|
|
330
|
+
other.adapter_path = adapter
|
|
331
|
+
other.adapter = adapter_digest(adapter)
|
|
332
|
+
if shared.lora is None:
|
|
333
|
+
other._lora_weights = None
|
|
334
|
+
elif adapter is None:
|
|
335
|
+
other._lora_weights = {key: mx.zeros_like(value) for key, value in lora_parameters(self.model).items()}
|
|
336
|
+
mx.eval(list(other._lora_weights.values()))
|
|
337
|
+
else:
|
|
338
|
+
other._lora_weights = mx.load(str(adapter / ADAPTER_WEIGHTS))
|
|
339
|
+
mx.eval(list(other._lora_weights.values()))
|
|
340
|
+
calibration.check(other.provenance)
|
|
341
|
+
other.calibration = calibration
|
|
342
|
+
return other
|
|
343
|
+
|
|
344
|
+
def _activate(self) -> None:
|
|
345
|
+
"""Put this engine's LoRA weights into the shared layers; call with the lock held."""
|
|
346
|
+
if self._lora_weights is not None and self._shared.active is not self:
|
|
347
|
+
self.model.load_weights(list(self._lora_weights.items()), strict=False)
|
|
348
|
+
self._shared.active = self
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def provenance(self) -> Provenance:
|
|
352
|
+
return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def pass_options(self) -> int:
|
|
356
|
+
return self.codes.max_options
|
|
357
|
+
|
|
358
|
+
# ---- prompt assembly -------------------------------------------------
|
|
359
|
+
|
|
360
|
+
def split_prompt(self, state: State, thinking: bool = False) -> tuple[str, str]:
|
|
361
|
+
"""Return the chat-templated prefix (through the state) and the template tail.
|
|
362
|
+
|
|
363
|
+
With `thinking`, the tail ends where the model starts reasoning instead of answering.
|
|
364
|
+
"""
|
|
365
|
+
rendered_state = render_state(state)
|
|
366
|
+
if SPLIT_MARKER in rendered_state:
|
|
367
|
+
raise ValueError("state contains the reserved split marker")
|
|
368
|
+
content = self._user_content(state, rendered_state)
|
|
369
|
+
if self._system_role:
|
|
370
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": content}]
|
|
371
|
+
else:
|
|
372
|
+
messages = [{"role": "user", "content": _prepend(SYSTEM_PROMPT + "\n\n", content)}]
|
|
373
|
+
text = self.tokenizer.apply_chat_template(
|
|
374
|
+
messages, tokenize=False, add_generation_prompt=True, enable_thinking=thinking
|
|
375
|
+
)
|
|
376
|
+
prefix, tail = text.split(SPLIT_MARKER)
|
|
377
|
+
return prefix, tail
|
|
378
|
+
|
|
379
|
+
def _user_content(self, state: State, rendered_state: str) -> str | list[dict]:
|
|
380
|
+
"""The user turn through the split marker."""
|
|
381
|
+
return rendered_state + SPLIT_MARKER
|
|
382
|
+
|
|
383
|
+
def _template_accepts_system_role(self) -> bool:
|
|
384
|
+
try:
|
|
385
|
+
self.tokenizer.apply_chat_template(
|
|
386
|
+
[{"role": "system", "content": "s"}, {"role": "user", "content": "u"}],
|
|
387
|
+
tokenize=False, add_generation_prompt=True, **TEMPLATE_KWARGS,
|
|
388
|
+
)
|
|
389
|
+
except Exception: # templates raise jinja TemplateError subclasses; the type varies by model
|
|
390
|
+
return False
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
# ---- scoring -----------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def _score_plain(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
|
|
396
|
+
prefix = self._prefix(state)
|
|
397
|
+
prefix_tokens = prefix.tokens
|
|
398
|
+
passes = [
|
|
399
|
+
_Pass(qid, rendered.keys, self._encode(rendered.text + prefix.tail))
|
|
400
|
+
for qid, question in questions.items()
|
|
401
|
+
for rendered in (render_question(question, self.codes.codes, r) for r in self._rotations_for(question))
|
|
402
|
+
]
|
|
403
|
+
widest = max((p for p in passes), key=lambda p: len(p.tokens), default=None)
|
|
404
|
+
if widest is not None and len(prefix_tokens) + len(widest.tokens) > self.max_branch_tokens:
|
|
405
|
+
raise InputTooLong(f"question {widest.question_id!r} makes a branch of "
|
|
406
|
+
f"{len(prefix_tokens) + len(widest.tokens)} tokens, over the maximum context length "
|
|
407
|
+
f"of {self.max_branch_tokens} for one branch", question_id=widest.question_id)
|
|
408
|
+
total = len(prefix_tokens) + sum(len(p.tokens) for p in passes)
|
|
409
|
+
if total > self.max_input_tokens:
|
|
410
|
+
raise InputTooLong(f"request is {total} tokens, over the maximum context length of "
|
|
411
|
+
f"{self.max_input_tokens} for a whole request")
|
|
412
|
+
|
|
413
|
+
per_question: dict[str, dict[str, list[float]]] = {qid: {} for qid in questions}
|
|
414
|
+
with self._lock:
|
|
415
|
+
self._activate()
|
|
416
|
+
prefix_cache = self._encode_prefix(prefix)
|
|
417
|
+
for chunk in self._chunks(passes, len(prefix_tokens)):
|
|
418
|
+
for one, logits in zip(chunk, self._forward_batch(prefix_cache, chunk, prefix)):
|
|
419
|
+
log_probs = log_softmax(logits)
|
|
420
|
+
if self.prior_debias:
|
|
421
|
+
log_probs = log_probs - self._prior(len(one.keys))
|
|
422
|
+
for key, value in zip(one.keys, log_probs):
|
|
423
|
+
per_question[one.question_id].setdefault(key, []).append(float(value))
|
|
424
|
+
|
|
425
|
+
raw = {}
|
|
426
|
+
for qid, question in questions.items():
|
|
427
|
+
keys = option_keys(question)
|
|
428
|
+
raw[qid] = RawScore(keys=keys, logits=np.array([np.mean(per_question[qid][k]) for k in keys]))
|
|
429
|
+
return Scored(raw=raw, input_tokens=total)
|
|
430
|
+
|
|
431
|
+
def _prior(self, cardinality: int) -> np.ndarray:
|
|
432
|
+
"""Log-probabilities the model assigns to each letter with no content to go on."""
|
|
433
|
+
if cardinality not in self._priors:
|
|
434
|
+
prefix = self._prefix(PRIOR_STATE)
|
|
435
|
+
rendered = render_question(prior_question(cardinality), self.codes.codes)
|
|
436
|
+
one = _Pass("prior", rendered.keys, self._encode(rendered.text + prefix.tail))
|
|
437
|
+
self._priors[cardinality] = log_softmax(self._forward_batch(self._encode_prefix(prefix), [one], prefix)[0])
|
|
438
|
+
return self._priors[cardinality]
|
|
439
|
+
|
|
440
|
+
def _chunks(self, passes: list[_Pass], prefix_length: int) -> list[list[_Pass]]:
|
|
441
|
+
chunks: list[list[_Pass]] = [[]]
|
|
442
|
+
for one in passes:
|
|
443
|
+
candidate = chunks[-1] + [one]
|
|
444
|
+
width = max(len(p.tokens) for p in candidate)
|
|
445
|
+
if chunks[-1] and len(candidate) * (prefix_length + width) > BATCH_TOKEN_BUDGET:
|
|
446
|
+
chunks.append([one])
|
|
447
|
+
else:
|
|
448
|
+
chunks[-1] = candidate
|
|
449
|
+
return chunks
|
|
450
|
+
|
|
451
|
+
def _forward_batch(self, prefix_cache, passes: list[_Pass], prefix: _Prefix) -> list[np.ndarray]:
|
|
452
|
+
"""One padded forward pass for every suffix, against replicas of the prefix cache."""
|
|
453
|
+
batch = len(passes)
|
|
454
|
+
width = max(len(p.tokens) for p in passes)
|
|
455
|
+
cache = make_prompt_cache(self.model)
|
|
456
|
+
for replica, original in zip(cache, prefix_cache):
|
|
457
|
+
state = original.state
|
|
458
|
+
if isinstance(state, tuple):
|
|
459
|
+
replica.state = tuple(mx.concatenate([a] * batch, axis=0) for a in state)
|
|
460
|
+
else:
|
|
461
|
+
replica.state = [None if a is None else mx.concatenate([a] * batch, axis=0) for a in state]
|
|
462
|
+
tokens = mx.array([p.tokens + [self._pad_id] * (width - len(p.tokens)) for p in passes])
|
|
463
|
+
last = mx.array([len(p.tokens) - 1 for p in passes])
|
|
464
|
+
cardinality = max(len(p.keys) for p in passes)
|
|
465
|
+
ids = mx.array([self.codes.token_ids[:cardinality] for _ in passes])
|
|
466
|
+
positions = suffix_positions(prefix, batch, width)
|
|
467
|
+
logits = np.array(option_logits(self.model, tokens, cache, last, ids, positions), dtype=np.float64)
|
|
468
|
+
return [row[: len(one.keys)] for row, one in zip(logits, passes)]
|
|
469
|
+
|
|
470
|
+
# ---- answers -----------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
|
|
473
|
+
scored = self.score(request.state, request.questions)
|
|
474
|
+
answers = {qid: self.answer(request.questions[qid], scored.raw[qid]) for qid in request.questions}
|
|
475
|
+
return SystemOneResponse(
|
|
476
|
+
model=self.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens)
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def answer(self, question: Choice | Score | Noul, raw: RawScore) -> ChoiceAnswer | ScoreAnswer | NoulAnswer:
|
|
480
|
+
return answer(question, raw, self.calibration)
|
|
481
|
+
|
|
482
|
+
# ---- helpers -----------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def _rotations_for(self, question: Choice | Score | Noul) -> range:
|
|
485
|
+
orderings = 2 if isinstance(question, Score) else len(option_keys(question))
|
|
486
|
+
return range(min(self.rotations, orderings))
|
|
487
|
+
|
|
488
|
+
def _encode(self, text: str) -> list[int]:
|
|
489
|
+
return self.tokenizer.encode(text, add_special_tokens=False)
|
|
490
|
+
|
|
491
|
+
def _prefix(self, state: State) -> _Prefix:
|
|
492
|
+
prefix_text, tail = self.split_prompt(state)
|
|
493
|
+
return _Prefix(self._encode(prefix_text), tail)
|
|
494
|
+
|
|
495
|
+
def _encode_prefix(self, prefix: _Prefix):
|
|
496
|
+
tokens = prefix.tokens
|
|
497
|
+
cache = make_prompt_cache(self.model)
|
|
498
|
+
array = mx.array(tokens)[None]
|
|
499
|
+
for start in range(0, len(tokens), PREFILL_STEP):
|
|
500
|
+
self.model(array[:, start : start + PREFILL_STEP], cache=cache)
|
|
501
|
+
mx.eval([c.state for c in cache])
|
|
502
|
+
return cache
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def suffix_positions(prefix: _Prefix, batch: int, width: int) -> mx.array | None:
|
|
506
|
+
"""Multimodal position ids for `batch` suffixes of `width` tokens after `prefix`, or None
|
|
507
|
+
when the model counts positions from the cache as usual: after images, a token's
|
|
508
|
+
position on every axis is its index plus the prefix's (negative) delta."""
|
|
509
|
+
if not prefix.position_delta:
|
|
510
|
+
return None
|
|
511
|
+
row = mx.arange(width) + len(prefix.tokens) + prefix.position_delta
|
|
512
|
+
return mx.broadcast_to(row[None, None, :], (3, batch, width))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _prepend(text: str, content: str | list[dict]) -> str | list[dict]:
|
|
516
|
+
if isinstance(content, str):
|
|
517
|
+
return text + content
|
|
518
|
+
return [{"type": "text", "text": text}, *content]
|