gemmadecision 0.1.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.
@@ -0,0 +1,42 @@
1
+ """Typed decisions without importing torch, vLLM, or loading a model."""
2
+ from .client import AsyncDecisionClient, DecisionClient
3
+ from .types import Choice, Noul, Score, ChoiceAnswer, NoulAnswer, ScoreAnswer, SystemOneResponse, RankingResponse
4
+ from .constants import MODEL_ID, MODEL_REVISION
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["decide", "rank", "DecisionClient", "AsyncDecisionClient", "DecisionEngine", "GemmaDecisionModel", "Choice", "Noul", "Score",
8
+ "ChoiceAnswer", "NoulAnswer", "ScoreAnswer", "SystemOneResponse", "RankingResponse"]
9
+
10
+
11
+ def __getattr__(name):
12
+ if name == "DecisionEngine":
13
+ from .engine import DecisionEngine
14
+ return DecisionEngine
15
+ if name == "GemmaDecisionModel":
16
+ from .integrations.pydantic_ai import GemmaDecisionModel
17
+ return GemmaDecisionModel
18
+ raise AttributeError(name)
19
+
20
+
21
+ def decide(state, choices, *, question="Choose the best option.") -> str:
22
+ """Choose locally in one call; first use downloads/loads the pinned model."""
23
+ from .simple import get_engine
24
+ from .types import RankRequest
25
+ if isinstance(choices, (list, tuple)):
26
+ if any(not isinstance(text, str) for text in choices):
27
+ raise ValueError("Choices must be strings or a mapping of labels to descriptions")
28
+ if len(set(choices)) != len(choices):
29
+ raise ValueError("Choices must be distinct")
30
+ choices = {text: text for text in choices}
31
+ choices = RankRequest(state=state, candidates=choices, question=question).candidates
32
+ if len(set(choices.values())) != len(choices) or any(not text.strip() for text in choices.values()):
33
+ raise ValueError("Choice descriptions must be distinct and nonempty")
34
+ return get_engine().decide(state, choices, question=question).choice
35
+
36
+
37
+ def rank(state, choices, *, question=""):
38
+ """Detailed local ranking with lazy model loading, no server required."""
39
+ from .simple import get_engine
40
+ from .types import RankRequest
41
+ choices = RankRequest(state=state, candidates=choices, question=question).candidates
42
+ return get_engine().rank(state, choices, question=question)
@@ -0,0 +1,166 @@
1
+ """Shared question/answer schema for the CLM System One API.
2
+
3
+ The wire format is the TypeSafe ``POST /v1/systemone`` shape: a ``state``
4
+ (string, object or array) and a map of typed ``questions`` (``noul``,
5
+ ``choice``, ``score``), answered with probability distributions. This module
6
+ holds everything that both the server and the trainer must agree on:
7
+
8
+ * how a (state, question) pair is turned into the *state text* the state head
9
+ sees and the *candidate texts* the action head sees (``build_pairs``), and
10
+ * how per-candidate similarities become an Answer of the right type
11
+ (``answer_from_logits``).
12
+
13
+ The CLM scores each candidate by the scaled cosine between the projected state
14
+ and the projected candidate, exactly as in the InfoNCE training objective, and
15
+ a softmax over a question's candidates is that question's distribution.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ from typing import Any
22
+
23
+ QUESTION_TYPES = ("noul", "choice", "score")
24
+ NOUL_KEYS = ("false", "true")
25
+
26
+
27
+ def to_text(x: Any, indent: int = 0) -> str:
28
+ """Render a state / description that may be a string, object or array as plain text.
29
+
30
+ The heads are trained on prose, not on JSON, so an object becomes ``key: value``
31
+ fields (top-level fields separated by a blank line, nested ones indented) and an
32
+ array becomes one ``- item`` line per element. Key order is preserved.
33
+ """
34
+ if x is None:
35
+ return ""
36
+ if isinstance(x, str):
37
+ return x
38
+ if isinstance(x, bool):
39
+ return "true" if x else "false"
40
+ if isinstance(x, (int, float)):
41
+ return str(x)
42
+ pad = " " * indent
43
+ if isinstance(x, dict):
44
+ parts = []
45
+ for k, v in x.items():
46
+ if isinstance(v, (dict, list)) and v:
47
+ parts.append(f"{pad}{k}:\n{to_text(v, indent + 2)}")
48
+ else:
49
+ parts.append(f"{pad}{k}: {to_text(v)}")
50
+ return ("\n\n" if indent == 0 else "\n").join(parts)
51
+ if isinstance(x, (list, tuple)):
52
+ parts = []
53
+ for v in x:
54
+ if isinstance(v, (dict, list)) and v:
55
+ parts.append(f"{pad}-\n{to_text(v, indent + 2)}")
56
+ else:
57
+ parts.append(f"{pad}- {to_text(v)}")
58
+ return "\n".join(parts)
59
+ return json.dumps(x, ensure_ascii=False)
60
+
61
+
62
+ def state_text(state: Any, instructions: Any) -> str:
63
+ """Context first, question last — the layout the heads were trained on."""
64
+ s, i = to_text(state).strip(), to_text(instructions).strip()
65
+ return f"{s}\n\n{i}" if s and i else (s or i)
66
+
67
+
68
+ def _norm_question(q: dict) -> dict:
69
+ t = q.get("type")
70
+ if t not in QUESTION_TYPES:
71
+ raise ValueError(f"unknown question type {t!r}; expected one of {QUESTION_TYPES}")
72
+ return q
73
+
74
+
75
+ def candidates(q: dict) -> tuple[list[str], list[str]]:
76
+ """-> (option keys in answer order, candidate text per option)."""
77
+ q = _norm_question(q)
78
+ t, crit, ins = q["type"], q.get("criteria"), to_text(q.get("instructions")).strip()
79
+ if t == "choice":
80
+ if not isinstance(crit, dict) or not crit:
81
+ raise ValueError("choice question needs a non-empty 'criteria' object")
82
+ # The action head embeds the option's own text: its description when one is
83
+ # given, else the key. Nothing is prefixed, so a candidate reaches the encoder
84
+ # exactly as the caller wrote it (the heads are trained on plain answer text).
85
+ keys = list(crit)
86
+ texts = [to_text(crit[k]) if crit[k] not in (None, "") else k for k in keys]
87
+ return keys, texts
88
+ if t == "score":
89
+ if not isinstance(crit, list) or len(crit) < 2:
90
+ raise ValueError("score question needs 'criteria' as an ordered list of >= 2 levels")
91
+ keys = [str(i) for i in range(len(crit))]
92
+ return keys, [to_text(c) for c in crit]
93
+ # noul: optional {"true": ..., "false": ...} descriptions; default to the statement itself
94
+ crit = crit or {}
95
+ texts = []
96
+ for k in NOUL_KEYS:
97
+ d = crit.get(k) if isinstance(crit, dict) else None
98
+ if d in (None, ""):
99
+ d = (f"Yes. This is true: {ins}" if k == "true" else f"No. This is false: {ins}") if ins else k
100
+ texts.append(f"{k}: {to_text(d)}")
101
+ return list(NOUL_KEYS), texts
102
+
103
+
104
+ def build_pairs(state: Any, questions: dict[str, dict]) -> dict[str, tuple[str, list[str], list[str]]]:
105
+ """{qid: (state_text, option_keys, candidate_texts)} for every question.
106
+
107
+ The state head sees ``context + question``: the state is the context and the
108
+ question's instructions are the question, appended after a blank line. That is
109
+ the layout the heads are trained on, so callers should put the question in
110
+ ``instructions`` and not repeat it inside the state.
111
+ """
112
+ return {qid: (state_text(state, q.get("instructions")), *candidates(q)) for qid, q in questions.items()}
113
+
114
+
115
+ def softmax(logits: list[float]) -> list[float]:
116
+ m = max(logits)
117
+ e = [math.exp(v - m) for v in logits]
118
+ z = sum(e)
119
+ return [v / z for v in e]
120
+
121
+
122
+ def confidence(probs: list[float]) -> float:
123
+ """TypeSafe-style confidence: top probability minus the mean of the rest."""
124
+ if len(probs) < 2:
125
+ return 1.0
126
+ j = max(range(len(probs)), key=probs.__getitem__)
127
+ rest = [p for i, p in enumerate(probs) if i != j]
128
+ return max(0.0, min(1.0, probs[j] - sum(rest) / len(rest)))
129
+
130
+
131
+ def answer_from_probs(q: dict, keys: list[str], probs: list[float]) -> dict:
132
+ """Assemble the Answer object for question ``q`` from its option distribution."""
133
+ t = q["type"]
134
+ probs = [float(p) for p in probs]
135
+ dist = {k: p for k, p in zip(keys, probs)}
136
+ if t == "noul":
137
+ return {"type": "noul", "noul": dist["true"]}
138
+ if t == "choice":
139
+ j = max(range(len(probs)), key=probs.__getitem__)
140
+ return {"type": "choice", "choice": keys[j], "confidence": confidence(probs), "probabilities": dist}
141
+ levels = q["criteria"]
142
+ score = sum(i * p for i, p in enumerate(probs))
143
+ legend = {str(i): c if isinstance(c, str) else to_text(c) for i, c in enumerate(levels)}
144
+ return {"type": "score", "score": score, "confidence": confidence(probs), "legend": legend,
145
+ "probabilities": dist}
146
+
147
+
148
+ def answer_from_logits(q: dict, keys: list[str], logits: list[float]) -> dict:
149
+ return answer_from_probs(q, keys, softmax([float(v) for v in logits]))
150
+
151
+
152
+ def label_of(answer: dict) -> str:
153
+ """Discrete label of an answer (for scoring): choice key, score level, or 'true'/'false'."""
154
+ t = answer["type"]
155
+ if t == "choice":
156
+ return answer["choice"]
157
+ if t == "noul":
158
+ return "true" if answer["noul"] >= 0.5 else "false"
159
+ p = answer["probabilities"]
160
+ return max(p, key=p.__getitem__)
161
+
162
+
163
+ def probabilities_of(answer: dict) -> dict[str, float]:
164
+ if answer["type"] == "noul":
165
+ return {"false": 1.0 - answer["noul"], "true": answer["noul"]}
166
+ return dict(answer["probabilities"])
@@ -0,0 +1 @@
1
+ """Optional numerical backends, imported only on explicit model loading."""
@@ -0,0 +1,266 @@
1
+ """Batched inference for the published full joint GemmaDecision encoder.
2
+
3
+ Importing this module does not import torch, load weights, or use the network.
4
+ The caller renders the training-format strings and checks the separate state
5
+ and candidate token limits. This backend checks each complete pair's length.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+ from pathlib import Path
13
+ import threading
14
+ from typing import Any, Sequence
15
+
16
+
17
+ MODEL_REPOSITORY = "rajan2k/GemmaDecision-270M"
18
+ MODEL_REVISION = "785d530221c990671f29976902540101bb9c7647"
19
+
20
+ # These hashes are bound to the published v0.4.0 commit, not downloaded from
21
+ # the same untrusted directory as the model. Loading never executes model code.
22
+ FROZEN_FILES = {
23
+ "model.safetensors": "d7a3e291bfdfa7cd85b33a8a99ef81a4a7d3192e46c77253f3daf14dfd7d6b95",
24
+ "joint_head.safetensors": "72ec4e7d1f0908ad684eeae250f0f70128aa4342b46174bf92a3c9851c5dd965",
25
+ "joint_config.json": "d48f2b5ad5fbea6a3d6723017a6f6a114260a331f0b917b8008de14501091533",
26
+ "config.json": "c1c64396b2939c76f0fa091aa07815b6e6f5ac1a60bfe3878993f0f575dd3ff3",
27
+ "tokenizer.json": "7d4046bf0505a327dd5a0abbb427ecd4fc82f99c2ceaa170bc61ecde12809b0c",
28
+ "tokenizer.model": "1299c11d7cf632ef3b4e11937501358ada021bbdf7c47638d13c0ee982f2e79c",
29
+ "tokenizer_config.json": "94b03056ec5831e9021c3e3fe9db682778c4f2d081c4188f1fcb8b90541cf1cf",
30
+ "special_tokens_map.json": "2f7b0adf4fb469770bb1490e3e35df87b1dc578246c5e7e6fc76ecf33213a397",
31
+ "added_tokens.json": "50b2f405ba56a26d4913fd772089992252d7f942123cc0a034d96424221ba946",
32
+ }
33
+
34
+
35
+ def verify_model_files(path: str | Path) -> None:
36
+ """Verify the files consumed by inference against the pinned release."""
37
+ root = Path(path)
38
+ for name, expected in FROZEN_FILES.items():
39
+ file = root / name
40
+ if not file.is_file():
41
+ raise ValueError(f"The model directory is incomplete: missing {name}")
42
+ digest = hashlib.sha256()
43
+ with file.open("rb") as stream:
44
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
45
+ digest.update(block)
46
+ if digest.hexdigest() != expected:
47
+ raise ValueError(
48
+ f"Model integrity check failed for {name}; expected the published "
49
+ f"GemmaDecision v0.4.0 revision {MODEL_REVISION}"
50
+ )
51
+
52
+
53
+ def plan_batches(
54
+ lengths: Sequence[int], max_batch_tokens: int, max_batch_size: int
55
+ ) -> list[list[int]]:
56
+ """Bucket by length, bounding padded tokens and preserving all indices.
57
+
58
+ Returned indices are an execution plan; callers restore the original order.
59
+ The token bound counts padding (batch size times longest input), not merely
60
+ the sum of the unpadded lengths. No truncation or dropping is permitted.
61
+ """
62
+ if max_batch_tokens < 1 or max_batch_size < 1:
63
+ raise ValueError("Batch token and size limits must be positive")
64
+ if any(length < 1 for length in lengths):
65
+ raise ValueError("Every joint input must have at least one token")
66
+ if any(length > max_batch_tokens for length in lengths):
67
+ raise ValueError(
68
+ "A joint input exceeds max_batch_tokens; increase the batch token "
69
+ "budget to encode this input without truncation"
70
+ )
71
+ batches: list[list[int]] = []
72
+ current: list[int] = []
73
+ for index in sorted(range(len(lengths)), key=lambda i: (lengths[i], i)):
74
+ if current and (
75
+ len(current) == max_batch_size
76
+ or lengths[index] * (len(current) + 1) > max_batch_tokens
77
+ ):
78
+ batches.append(current)
79
+ current = []
80
+ current.append(index)
81
+ if current:
82
+ batches.append(current)
83
+ return batches
84
+
85
+
86
+ class TorchBackend:
87
+ """Load one full encoder and score joint inputs using bounded batches.
88
+
89
+ CUDA uses BF16 when supported; CPU and MPS use FP32. Scalar-head inputs and
90
+ parameters always use FP32, matching the frozen reference. ``strict=True``
91
+ uses one input per forward pass, matching its numerical batch shape. The
92
+ faster default may produce small batch-shape differences around near ties.
93
+
94
+ Instantiate once and reuse it. The lock prevents overlapping forwards from
95
+ separate caller threads; the serving scheduler should coalesce requests
96
+ into a single score_pairs call to obtain cross-request batching.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ model_path: str | Path,
102
+ *,
103
+ device: str = "auto",
104
+ max_batch_tokens: int = 8192,
105
+ max_batch_size: int = 32,
106
+ strict: bool = False,
107
+ verify: bool = True,
108
+ ) -> None:
109
+ self.path = Path(model_path)
110
+ if not self.path.is_dir():
111
+ raise ValueError("model_path must be a complete local model directory")
112
+ if max_batch_tokens < 1 or max_batch_size < 1:
113
+ raise ValueError("Batch token and size limits must be positive")
114
+ if verify:
115
+ verify_model_files(self.path)
116
+ self.config = json.loads((self.path / "joint_config.json").read_text())
117
+ if self.config.get("format") != "gemmadecision-full-joint-v4":
118
+ raise ValueError("This backend requires a full joint GemmaDecision v4 model")
119
+ self.max_state_tokens = int(self.config.get("max_state_tokens", 2048))
120
+ self.max_action_tokens = int(self.config.get("max_action_tokens", 768))
121
+ dimensions = self.config.get("head_config", {"hidden": 640, "width": 512})
122
+ hidden, width = int(dimensions["hidden"]), int(dimensions["width"])
123
+ if min(self.max_state_tokens, self.max_action_tokens, hidden, width) < 1:
124
+ raise ValueError("Token limits and head dimensions must be positive")
125
+ head_file = self.config.get("head_file", "joint_head.safetensors")
126
+ if not isinstance(head_file, str) or Path(head_file).name != head_file:
127
+ raise ValueError("head_file must name a file in the model directory")
128
+ try:
129
+ import torch
130
+ from safetensors.torch import load_file
131
+ from transformers import AutoModel, AutoTokenizer
132
+ except ImportError as error:
133
+ raise ImportError(
134
+ "Install the local inference dependencies: pip install 'gemmadecision[serve]'"
135
+ ) from error
136
+
137
+ self._torch = torch
138
+ if device == "auto":
139
+ device = (
140
+ "cuda" if torch.cuda.is_available()
141
+ else "mps" if torch.backends.mps.is_available()
142
+ else "cpu"
143
+ )
144
+ self.device = torch.device(device)
145
+ if self.device.type not in {"cpu", "mps", "cuda"}:
146
+ raise ValueError("Supported devices are cpu, mps and cuda")
147
+ if self.device.type == "cuda" and not torch.cuda.is_available():
148
+ raise ValueError("CUDA is unavailable on this machine")
149
+ if self.device.type == "mps" and not torch.backends.mps.is_available():
150
+ raise ValueError("MPS is unavailable on this machine")
151
+ dtype = (
152
+ torch.bfloat16
153
+ if self.device.type == "cuda" and torch.cuda.is_bf16_supported()
154
+ else torch.float32
155
+ )
156
+ self.dtype = str(dtype).removeprefix("torch.")
157
+ self.tokenizer = AutoTokenizer.from_pretrained(
158
+ self.path, local_files_only=True, trust_remote_code=False
159
+ )
160
+ # Last-token pooling below assumes right padding; do not inherit an
161
+ # arbitrary tokenizer padding side from the calling environment.
162
+ self.tokenizer.padding_side = "right"
163
+ if self.tokenizer.pad_token_id is None:
164
+ raise ValueError("The model tokenizer must define a padding token")
165
+ self.encoder = AutoModel.from_pretrained(
166
+ self.path,
167
+ local_files_only=True,
168
+ trust_remote_code=False,
169
+ use_safetensors=True,
170
+ dtype=dtype,
171
+ attn_implementation="sdpa",
172
+ ).to(self.device).eval().requires_grad_(False)
173
+ if self.encoder.config.hidden_size != hidden:
174
+ raise ValueError("Scalar head width does not match the encoder hidden size")
175
+ self.context_limit = getattr(self.encoder.config, "max_position_embeddings", None)
176
+ self.head = torch.nn.Sequential(
177
+ torch.nn.LayerNorm(hidden),
178
+ torch.nn.Linear(hidden, width),
179
+ torch.nn.GELU(),
180
+ torch.nn.Linear(width, 1),
181
+ ).to(device=self.device, dtype=torch.float32).eval().requires_grad_(False)
182
+ weights = load_file(str(self.path / head_file), device="cpu")
183
+ if any(not torch.isfinite(value).all() for value in weights.values()):
184
+ raise ValueError("Non-finite scalar head weights")
185
+ self.head.load_state_dict(weights, strict=True)
186
+ self.max_batch_tokens = max_batch_tokens
187
+ self.max_batch_size = 1 if strict else max_batch_size
188
+ self.strict = strict
189
+ self.verified = verify
190
+ self._forward_lock = threading.Lock()
191
+
192
+ def count_tokens(self, text: str) -> int:
193
+ """Count tokens including the same BOS/special tokens used for scoring."""
194
+ if not isinstance(text, str):
195
+ raise TypeError("Token-count input must be a string")
196
+ return len(self.tokenizer(
197
+ text, add_special_tokens=True, truncation=False, padding=False
198
+ )["input_ids"])
199
+
200
+ def score_pairs(self, texts: Sequence[str]) -> list[float]:
201
+ """Score full training-format state/candidate strings, in input order.
202
+
203
+ The caller must separately enforce max_state_tokens/max_action_tokens
204
+ on the original fields before combining them. This method cannot recover
205
+ those field boundaries reliably from arbitrary text. An empty list is
206
+ allowed for scheduler convenience; empty individual inputs are not.
207
+ """
208
+ if isinstance(texts, (str, bytes)) or not isinstance(texts, Sequence):
209
+ raise TypeError("Joint inputs must be a sequence of strings")
210
+ if not texts:
211
+ return []
212
+ if any(not isinstance(text, str) or not text for text in texts):
213
+ raise ValueError("Every joint input must be a nonempty string")
214
+ tokens = self.tokenizer(
215
+ list(texts), add_special_tokens=True, truncation=False, padding=False
216
+ )["input_ids"]
217
+ lengths = [len(row) for row in tokens]
218
+ if self.context_limit is not None and any(
219
+ length > self.context_limit for length in lengths
220
+ ):
221
+ raise ValueError(f"A joint input exceeds the encoder limit of {self.context_limit} tokens")
222
+ batches = plan_batches(lengths, self.max_batch_tokens, self.max_batch_size)
223
+ scores = [0.0] * len(texts)
224
+ # inference_mode is entered here because it is thread-local: a long-lived
225
+ # worker may call the backend from different executor threads.
226
+ with self._forward_lock, self._torch.inference_mode():
227
+ for indices in batches:
228
+ batch_scores = self._score_tokens([tokens[i] for i in indices])
229
+ for index, score in zip(indices, batch_scores, strict=True):
230
+ scores[index] = score
231
+ return scores
232
+
233
+ def _score_tokens(self, tokens: Sequence[Sequence[int]]) -> list[float]:
234
+ torch = self._torch
235
+ batch = self.tokenizer.pad(
236
+ {"input_ids": tokens}, padding=True, return_attention_mask=True,
237
+ return_tensors="pt",
238
+ )
239
+ ids = batch["input_ids"].to(self.device)
240
+ mask = batch["attention_mask"].to(self.device)
241
+ hidden = self.encoder(
242
+ input_ids=ids, attention_mask=mask, use_cache=False
243
+ ).last_hidden_state
244
+ positions = mask.sum(-1) - 1
245
+ last = hidden[torch.arange(len(ids), device=self.device), positions].float()
246
+ vectors = torch.nn.functional.normalize(last, dim=-1)
247
+ if not torch.isfinite(vectors).all():
248
+ raise RuntimeError("Non-finite model encoding")
249
+ scores = self.head(vectors).reshape(-1).cpu().tolist()
250
+ if any(not math.isfinite(score) for score in scores):
251
+ raise RuntimeError("Non-finite ranking score")
252
+ return scores
253
+
254
+ @property
255
+ def metadata(self) -> dict[str, Any]:
256
+ return {
257
+ "backend": "torch",
258
+ "device": str(self.device),
259
+ "dtype": self.dtype,
260
+ "head_dtype": "float32",
261
+ "strict": self.strict,
262
+ "max_batch_tokens": self.max_batch_tokens,
263
+ "max_batch_size": self.max_batch_size,
264
+ "model_revision": MODEL_REVISION if self.verified else None,
265
+ "verified": self.verified,
266
+ }
@@ -0,0 +1,144 @@
1
+ """Optional vLLM pooling backend for the *joint* GemmaDecision encoder.
2
+
3
+ No dependency, model, CUDA context, or network request is loaded at import.
4
+ This backend is experimental until its deployment-specific GPU parity passes.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from importlib.metadata import version
9
+ import json
10
+ import math
11
+ from pathlib import Path
12
+ from threading import Lock
13
+
14
+ from ..export_vllm import POOLER_CONFIG, VLLM_VERSION, export_vllm
15
+
16
+
17
+ class VLLMBackend:
18
+ """Batch complete state/candidate pairs in vLLM; run the trained head FP32.
19
+
20
+ The decoder stays on CUDA. Its 640-element last-token vectors are returned
21
+ to a tiny CPU FP32 head. This avoids depending on internal vLLM worker APIs
22
+ and preserves the original FP32 normalization and scalar-head precision.
23
+ """
24
+
25
+ name = "vllm"
26
+
27
+ def __init__(
28
+ self,
29
+ model_path: str | Path,
30
+ *,
31
+ export_path: str | Path | None = None,
32
+ gpu_memory_utilization: float = 0.20,
33
+ max_model_len: int = 3072,
34
+ max_num_seqs: int = 64,
35
+ enforce_eager: bool = True,
36
+ ) -> None:
37
+ try:
38
+ installed = version("vllm")
39
+ except Exception as error:
40
+ raise ImportError("Install gemmadecision[vllm] on a supported Linux GPU host") from error
41
+ if installed != VLLM_VERSION:
42
+ raise RuntimeError(f"This backend requires vllm=={VLLM_VERSION}; found {installed}")
43
+ if not 0 < gpu_memory_utilization < 1:
44
+ raise ValueError("gpu_memory_utilization must be between zero and one")
45
+ if max_num_seqs < 1 or max_model_len < 1:
46
+ raise ValueError("vLLM sequence limits must be positive")
47
+ import torch
48
+ from safetensors.torch import load_file
49
+ from transformers import AutoTokenizer
50
+ from vllm import LLM, PoolingParams
51
+ from vllm.config import PoolerConfig
52
+
53
+ if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported():
54
+ raise RuntimeError("The vLLM backend requires a CUDA GPU with bfloat16 support")
55
+ self.path = Path(model_path).expanduser().resolve()
56
+ from .torch import verify_model_files
57
+ verify_model_files(self.path)
58
+ config = json.loads((self.path / "joint_config.json").read_text())
59
+ encoder_config = json.loads((self.path / "config.json").read_text())
60
+ self.max_state_tokens = int(config["max_state_tokens"])
61
+ self.max_action_tokens = int(config["max_action_tokens"])
62
+ self.context_limit = min(int(encoder_config["max_position_embeddings"]), max_model_len)
63
+ self.device = "cuda"
64
+ self.dtype = "bfloat16"
65
+ self.head_device = "cpu"
66
+ self.hidden = int(config["head_config"]["hidden"])
67
+ width = int(config["head_config"]["width"])
68
+ head_file = config["head_file"]
69
+ if not isinstance(head_file, str) or Path(head_file).name != head_file:
70
+ raise ValueError("head_file must be a filename in the model directory")
71
+ self.tokenizer = AutoTokenizer.from_pretrained(
72
+ self.path, local_files_only=True, trust_remote_code=False)
73
+ self.head = torch.nn.Sequential(
74
+ torch.nn.LayerNorm(self.hidden), torch.nn.Linear(self.hidden, width),
75
+ torch.nn.GELU(), torch.nn.Linear(width, 1),
76
+ ).to(device="cpu", dtype=torch.float32).eval().requires_grad_(False)
77
+ weights = load_file(str(self.path / head_file), device="cpu")
78
+ if any(not torch.isfinite(weight).all() for weight in weights.values()):
79
+ raise ValueError("Non-finite scalar head weights")
80
+ self.head.load_state_dict(weights, strict=True)
81
+ self.encoder_path = export_vllm(self.path, export_path)
82
+ self._pooling_params = PoolingParams(use_activation=False)
83
+ self._lock = Lock()
84
+ self.llm = LLM(
85
+ model=str(self.encoder_path), tokenizer=str(self.encoder_path),
86
+ runner="pooling", convert="embed", dtype="bfloat16",
87
+ trust_remote_code=False, pooler_config=PoolerConfig(**POOLER_CONFIG),
88
+ max_model_len=self.context_limit, max_num_seqs=max_num_seqs,
89
+ gpu_memory_utilization=gpu_memory_utilization,
90
+ enforce_eager=enforce_eager, enable_prefix_caching=False,
91
+ tensor_parallel_size=1, seed=0,
92
+ )
93
+
94
+ def _tokens(self, text: str) -> list[int]:
95
+ if not isinstance(text, str):
96
+ raise TypeError("Input text must be a string")
97
+ return self.tokenizer(text, add_special_tokens=True, truncation=False,
98
+ padding=False)["input_ids"]
99
+
100
+ def count_tokens(self, text: str) -> int:
101
+ return len(self._tokens(text))
102
+
103
+ def score_pairs(self, texts: list[str]) -> list[float]:
104
+ """Score already-rendered joint pairs, preserving input order.
105
+
106
+ Explicit token IDs ensure exactly the frozen tokenizer's BOS behavior.
107
+ No chat template, truncation, probabilities, candidate-only embedding,
108
+ or independently cached action representation is used.
109
+ """
110
+ if not texts:
111
+ return []
112
+ tokens = [self._tokens(text) for text in texts]
113
+ for ids in tokens:
114
+ if not ids or len(ids) > self.context_limit:
115
+ raise ValueError(f"Joint input must contain 1–{self.context_limit} tokens")
116
+ import torch
117
+ with self._lock, torch.inference_mode():
118
+ outputs = self.llm.embed(
119
+ [{"prompt_token_ids": ids} for ids in tokens],
120
+ pooling_params=self._pooling_params, use_tqdm=False,
121
+ )
122
+ if len(outputs) != len(tokens):
123
+ raise RuntimeError("vLLM returned an unexpected number of embeddings")
124
+ vectors = torch.tensor([item.outputs.embedding for item in outputs],
125
+ dtype=torch.float32, device="cpu")
126
+ if vectors.shape != (len(tokens), self.hidden) or not torch.isfinite(vectors).all():
127
+ raise RuntimeError("vLLM returned invalid last-token hidden states")
128
+ vectors = torch.nn.functional.normalize(vectors, dim=-1)
129
+ scores = self.head(vectors).flatten().tolist()
130
+ if len(scores) != len(texts) or not all(math.isfinite(score) for score in scores):
131
+ raise RuntimeError("Non-finite or malformed ranking scores")
132
+ return scores
133
+
134
+ score_texts = score_pairs
135
+
136
+ @property
137
+ def metadata(self) -> dict:
138
+ from .torch import MODEL_REVISION
139
+ return {"backend": "vllm", "vllm_version": VLLM_VERSION,
140
+ "device": self.device, "dtype": self.dtype,
141
+ "head_device": self.head_device, "head_dtype": "float32",
142
+ "pooling_type": "LAST", "normalization": "float32_after_pooling",
143
+ "context_limit": self.context_limit, "prefix_caching": False,
144
+ "model_revision": MODEL_REVISION, "verified": True}