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/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]