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.
- gemmadecision/__init__.py +42 -0
- gemmadecision/_clm_schema.py +166 -0
- gemmadecision/backends/__init__.py +1 -0
- gemmadecision/backends/torch.py +266 -0
- gemmadecision/backends/vllm.py +144 -0
- gemmadecision/batching.py +145 -0
- gemmadecision/cli.py +73 -0
- gemmadecision/client.py +114 -0
- gemmadecision/constants.py +6 -0
- gemmadecision/engine.py +244 -0
- gemmadecision/export_vllm.py +121 -0
- gemmadecision/integrations/__init__.py +1 -0
- gemmadecision/integrations/pydantic_ai.py +331 -0
- gemmadecision/model.py +16 -0
- gemmadecision/py.typed +0 -0
- gemmadecision/server.py +119 -0
- gemmadecision/simple.py +17 -0
- gemmadecision/types.py +102 -0
- gemmadecision-0.1.0.dist-info/METADATA +156 -0
- gemmadecision-0.1.0.dist-info/RECORD +24 -0
- gemmadecision-0.1.0.dist-info/WHEEL +4 -0
- gemmadecision-0.1.0.dist-info/entry_points.txt +2 -0
- gemmadecision-0.1.0.dist-info/licenses/LICENSE +202 -0
- gemmadecision-0.1.0.dist-info/licenses/NOTICE +16 -0
|
@@ -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}
|