jev-compatible-server 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.
- jev_compatible_server/__init__.py +5 -0
- jev_compatible_server/app.py +144 -0
- jev_compatible_server/backends.py +320 -0
- jev_compatible_server/batching.py +68 -0
- jev_compatible_server/bosun.py +229 -0
- jev_compatible_server/causal_options.py +228 -0
- jev_compatible_server/classifier_adapters.py +472 -0
- jev_compatible_server/cross_encoder.py +71 -0
- jev_compatible_server/custom_heads.py +704 -0
- jev_compatible_server/encoder_decoder.py +630 -0
- jev_compatible_server/gliner2.py +40 -0
- jev_compatible_server/hidden_state_probe.py +384 -0
- jev_compatible_server/laya.py +135 -0
- jev_compatible_server/native_systemone.py +248 -0
- jev_compatible_server/protocol.py +104 -0
- jev_compatible_server/public-models.json +981 -0
- jev_compatible_server/registry.py +283 -0
- jev_compatible_server/runtime.py +237 -0
- jev_compatible_server/sequence_classifier.py +219 -0
- jev_compatible_server-0.1.0.dist-info/METADATA +157 -0
- jev_compatible_server-0.1.0.dist-info/RECORD +23 -0
- jev_compatible_server-0.1.0.dist-info/WHEEL +4 -0
- jev_compatible_server-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""Generic encoder-decoder margin readout configured by model recipes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .protocol import (
|
|
13
|
+
ChoiceAnswer,
|
|
14
|
+
ChoiceQuestion,
|
|
15
|
+
DecisionRequest,
|
|
16
|
+
DecisionResponse,
|
|
17
|
+
NoulAnswer,
|
|
18
|
+
NoulQuestion,
|
|
19
|
+
ScoreAnswer,
|
|
20
|
+
ScoreQuestion,
|
|
21
|
+
Usage,
|
|
22
|
+
)
|
|
23
|
+
from .runtime import DecisionRuntime, RuntimeErrorBase, softmax
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class MarginTask:
|
|
28
|
+
question_id: str
|
|
29
|
+
option_id: str
|
|
30
|
+
instruction: str
|
|
31
|
+
query: str
|
|
32
|
+
document: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class EncodedDocument:
|
|
37
|
+
hidden: Any
|
|
38
|
+
mask: Any
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def nbytes(self) -> int:
|
|
42
|
+
return int(
|
|
43
|
+
self.hidden.numel() * self.hidden.element_size()
|
|
44
|
+
+ self.mask.numel() * self.mask.element_size()
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decision_metadata(config: Mapping[str, Any]) -> dict[str, Any]:
|
|
49
|
+
"""Read nested decision metadata while retaining dotted-key compatibility."""
|
|
50
|
+
|
|
51
|
+
nested = config.get("decision", {})
|
|
52
|
+
result = dict(nested) if isinstance(nested, dict) else {}
|
|
53
|
+
for key, value in config.items():
|
|
54
|
+
if key.startswith("decision."):
|
|
55
|
+
result[key.removeprefix("decision.")] = value
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_content(value: Any) -> str:
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
return value
|
|
62
|
+
try:
|
|
63
|
+
return json.dumps(
|
|
64
|
+
value,
|
|
65
|
+
ensure_ascii=False,
|
|
66
|
+
sort_keys=True,
|
|
67
|
+
separators=(",", ":"),
|
|
68
|
+
allow_nan=False,
|
|
69
|
+
)
|
|
70
|
+
except (TypeError, ValueError) as exc:
|
|
71
|
+
raise RuntimeErrorBase("decision content must be JSON serializable") from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _mapping(value: Any, name: str) -> dict[str, Any]:
|
|
75
|
+
if not isinstance(value, dict):
|
|
76
|
+
raise RuntimeErrorBase(f"{name} must be an object")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _template(value: Any, name: str) -> str:
|
|
81
|
+
if not isinstance(value, str):
|
|
82
|
+
raise RuntimeErrorBase(f"{name} must be a string")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compile_margin_tasks(
|
|
87
|
+
request: DecisionRequest, metadata: Mapping[str, Any]
|
|
88
|
+
) -> list[MarginTask]:
|
|
89
|
+
"""Compile Jev questions into generic query/document relevance tasks."""
|
|
90
|
+
|
|
91
|
+
adapters = _mapping(metadata.get("instruction_adapters"), "decision.instruction_adapters")
|
|
92
|
+
candidates = _mapping(metadata.get("candidates"), "decision.candidates")
|
|
93
|
+
choice_config = _mapping(candidates.get("choice"), "decision.candidates.choice")
|
|
94
|
+
score_config = _mapping(candidates.get("score"), "decision.candidates.score")
|
|
95
|
+
noul_config = _mapping(candidates.get("noul"), "decision.candidates.noul")
|
|
96
|
+
query = render_content(request.state)
|
|
97
|
+
tasks: list[MarginTask] = []
|
|
98
|
+
|
|
99
|
+
for question_id, question in request.questions.items():
|
|
100
|
+
adapter = _template(adapters.get(question.type), f"decision.instruction_adapters.{question.type}")
|
|
101
|
+
original_instruction = render_content(question.instructions)
|
|
102
|
+
separator = metadata.get("instruction_adapter_separator", "\n\n")
|
|
103
|
+
if not isinstance(separator, str):
|
|
104
|
+
raise RuntimeErrorBase("decision.instruction_adapter_separator must be a string")
|
|
105
|
+
instruction = (
|
|
106
|
+
f"{original_instruction}{separator}{adapter}"
|
|
107
|
+
if adapter
|
|
108
|
+
else original_instruction
|
|
109
|
+
)
|
|
110
|
+
documents: list[tuple[str, str]]
|
|
111
|
+
if isinstance(question, ChoiceQuestion):
|
|
112
|
+
populated = _template(
|
|
113
|
+
choice_config.get("criterion_template"),
|
|
114
|
+
"decision.candidates.choice.criterion_template",
|
|
115
|
+
)
|
|
116
|
+
empty = _template(
|
|
117
|
+
choice_config.get("empty_criterion_template"),
|
|
118
|
+
"decision.candidates.choice.empty_criterion_template",
|
|
119
|
+
)
|
|
120
|
+
documents = []
|
|
121
|
+
for key, criterion in question.criteria.items():
|
|
122
|
+
template = empty if criterion is None else populated
|
|
123
|
+
documents.append(
|
|
124
|
+
(
|
|
125
|
+
key,
|
|
126
|
+
template.format(key=key, criterion=render_content(criterion)),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
elif isinstance(question, ScoreQuestion):
|
|
130
|
+
template = _template(
|
|
131
|
+
score_config.get("criterion_template"),
|
|
132
|
+
"decision.candidates.score.criterion_template",
|
|
133
|
+
)
|
|
134
|
+
documents = [
|
|
135
|
+
(str(index), template.format(criterion=render_content(criterion)))
|
|
136
|
+
for index, criterion in enumerate(question.criteria)
|
|
137
|
+
]
|
|
138
|
+
elif isinstance(question, NoulQuestion) and question.criteria is None:
|
|
139
|
+
no_criteria_options = noul_config.get("no_criteria_options")
|
|
140
|
+
if isinstance(no_criteria_options, dict):
|
|
141
|
+
documents = [
|
|
142
|
+
(str(key), render_content(value))
|
|
143
|
+
for key, value in no_criteria_options.items()
|
|
144
|
+
]
|
|
145
|
+
else:
|
|
146
|
+
template = _template(
|
|
147
|
+
noul_config.get("no_criteria_template"),
|
|
148
|
+
"decision.candidates.noul.no_criteria_template",
|
|
149
|
+
)
|
|
150
|
+
documents = [("", template.format(state=query))]
|
|
151
|
+
elif isinstance(question, NoulQuestion):
|
|
152
|
+
template = _template(
|
|
153
|
+
noul_config.get("criterion_template"),
|
|
154
|
+
"decision.candidates.noul.criterion_template",
|
|
155
|
+
)
|
|
156
|
+
if question.criteria is None: # pragma: no cover - narrowed above
|
|
157
|
+
raise RuntimeErrorBase("noul criteria unexpectedly missing")
|
|
158
|
+
documents = [
|
|
159
|
+
("true", template.format(criterion=render_content(question.criteria.true))),
|
|
160
|
+
("false", template.format(criterion=render_content(question.criteria.false))),
|
|
161
|
+
]
|
|
162
|
+
else: # pragma: no cover - exhaustive protocol union
|
|
163
|
+
raise RuntimeErrorBase(f"unsupported question type: {type(question).__name__}")
|
|
164
|
+
tasks.extend(
|
|
165
|
+
MarginTask(question_id, option_id, instruction, query, document)
|
|
166
|
+
for option_id, document in documents
|
|
167
|
+
)
|
|
168
|
+
return tasks
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def normalized_entropy_confidence(probabilities: Sequence[float]) -> float:
|
|
172
|
+
if len(probabilities) == 1:
|
|
173
|
+
return 1.0
|
|
174
|
+
entropy = -math.fsum(
|
|
175
|
+
probability * math.log(probability)
|
|
176
|
+
for probability in probabilities
|
|
177
|
+
if probability > 0
|
|
178
|
+
)
|
|
179
|
+
return min(1.0, max(0.0, 1.0 - entropy / math.log(len(probabilities))))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _sigmoid(value: float) -> float:
|
|
183
|
+
if value >= 0:
|
|
184
|
+
return 1.0 / (1.0 + math.exp(-value))
|
|
185
|
+
exp_value = math.exp(value)
|
|
186
|
+
return exp_value / (1.0 + exp_value)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def aggregate_margin_answers(
|
|
190
|
+
request: DecisionRequest,
|
|
191
|
+
tasks: Sequence[MarginTask],
|
|
192
|
+
margins: Sequence[float],
|
|
193
|
+
metadata: Mapping[str, Any],
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
if len(tasks) != len(margins) or not all(math.isfinite(value) for value in margins):
|
|
196
|
+
raise RuntimeErrorBase("encoder-decoder backend returned invalid margins")
|
|
197
|
+
aggregation = _mapping(metadata.get("aggregation"), "decision.aggregation")
|
|
198
|
+
choice_temperature = float(aggregation.get("choice_temperature", 1.0))
|
|
199
|
+
score_temperature = float(aggregation.get("score_temperature", 1.0))
|
|
200
|
+
noul_a = float(aggregation.get("noul_a", 1.0))
|
|
201
|
+
noul_b = float(aggregation.get("noul_b", 0.0))
|
|
202
|
+
if (
|
|
203
|
+
choice_temperature <= 0
|
|
204
|
+
or score_temperature <= 0
|
|
205
|
+
or noul_a <= 0
|
|
206
|
+
or not all(
|
|
207
|
+
math.isfinite(value)
|
|
208
|
+
for value in (choice_temperature, score_temperature, noul_a, noul_b)
|
|
209
|
+
)
|
|
210
|
+
):
|
|
211
|
+
raise RuntimeErrorBase("decision aggregation parameters are invalid")
|
|
212
|
+
confidence_method = aggregation.get("confidence", "normalized_entropy")
|
|
213
|
+
if confidence_method not in {"normalized_entropy", "max_probability"}:
|
|
214
|
+
raise RuntimeErrorBase(
|
|
215
|
+
"decision aggregation confidence must be normalized_entropy or max_probability"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
grouped: dict[str, list[tuple[str, float]]] = {
|
|
219
|
+
question_id: [] for question_id in request.questions
|
|
220
|
+
}
|
|
221
|
+
for task, margin in zip(tasks, margins, strict=True):
|
|
222
|
+
grouped[task.question_id].append((task.option_id, margin))
|
|
223
|
+
|
|
224
|
+
answers: dict[str, Any] = {}
|
|
225
|
+
for question_id, question in request.questions.items():
|
|
226
|
+
candidates = grouped[question_id]
|
|
227
|
+
if isinstance(question, NoulQuestion):
|
|
228
|
+
noul_mode = aggregation.get("noul_mode", "single_margin")
|
|
229
|
+
if noul_mode == "true_false_softmax":
|
|
230
|
+
by_label = dict(candidates)
|
|
231
|
+
if len(candidates) != 2 or set(by_label) != {"true", "false"}:
|
|
232
|
+
raise RuntimeErrorBase(
|
|
233
|
+
"true_false_softmax noul requires true and false margins"
|
|
234
|
+
)
|
|
235
|
+
margin = by_label["true"] - by_label["false"]
|
|
236
|
+
elif noul_mode != "single_margin":
|
|
237
|
+
raise RuntimeErrorBase(
|
|
238
|
+
"decision aggregation noul_mode must be single_margin or true_false_softmax"
|
|
239
|
+
)
|
|
240
|
+
elif question.criteria is None:
|
|
241
|
+
if len(candidates) != 1 or candidates[0][0] != "":
|
|
242
|
+
raise RuntimeErrorBase(
|
|
243
|
+
"noul without criteria requires exactly one state margin"
|
|
244
|
+
)
|
|
245
|
+
margin = candidates[0][1]
|
|
246
|
+
else:
|
|
247
|
+
by_label = dict(candidates)
|
|
248
|
+
if len(candidates) != 2 or set(by_label) != {"true", "false"}:
|
|
249
|
+
raise RuntimeErrorBase(
|
|
250
|
+
"noul criteria require exactly one true and one false margin"
|
|
251
|
+
)
|
|
252
|
+
margin = by_label["true"] - by_label["false"]
|
|
253
|
+
answers[question_id] = NoulAnswer(
|
|
254
|
+
type="noul", noul=_sigmoid(noul_a * margin + noul_b)
|
|
255
|
+
)
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
temperature = (
|
|
259
|
+
choice_temperature if isinstance(question, ChoiceQuestion) else score_temperature
|
|
260
|
+
)
|
|
261
|
+
probabilities = softmax([margin / temperature for _, margin in candidates])
|
|
262
|
+
distribution = {
|
|
263
|
+
option_id: probability
|
|
264
|
+
for (option_id, _), probability in zip(candidates, probabilities, strict=True)
|
|
265
|
+
}
|
|
266
|
+
confidence = (
|
|
267
|
+
normalized_entropy_confidence(probabilities)
|
|
268
|
+
if confidence_method == "normalized_entropy"
|
|
269
|
+
else max(probabilities)
|
|
270
|
+
)
|
|
271
|
+
if isinstance(question, ChoiceQuestion):
|
|
272
|
+
choice = max(distribution, key=distribution.__getitem__)
|
|
273
|
+
answers[question_id] = ChoiceAnswer(
|
|
274
|
+
type="choice",
|
|
275
|
+
choice=choice,
|
|
276
|
+
probabilities=distribution,
|
|
277
|
+
confidence=confidence,
|
|
278
|
+
)
|
|
279
|
+
elif isinstance(question, ScoreQuestion):
|
|
280
|
+
answers[question_id] = ScoreAnswer(
|
|
281
|
+
type="score",
|
|
282
|
+
score=math.fsum(
|
|
283
|
+
index * probability
|
|
284
|
+
for index, probability in enumerate(probabilities)
|
|
285
|
+
),
|
|
286
|
+
probabilities=distribution,
|
|
287
|
+
confidence=confidence,
|
|
288
|
+
legend=question.criteria,
|
|
289
|
+
)
|
|
290
|
+
else: # pragma: no cover - exhaustive protocol union
|
|
291
|
+
raise RuntimeErrorBase(f"unsupported question type: {type(question).__name__}")
|
|
292
|
+
return answers
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class EncoderDecoderMarginBackend(DecisionRuntime):
|
|
296
|
+
"""Encoder-decoder reranker whose entire behavior comes from model metadata."""
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
model_id: str,
|
|
301
|
+
*,
|
|
302
|
+
config: dict[str, Any] | None = None,
|
|
303
|
+
device: str = "auto",
|
|
304
|
+
):
|
|
305
|
+
try:
|
|
306
|
+
import torch
|
|
307
|
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
|
308
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
309
|
+
raise RuntimeErrorBase(
|
|
310
|
+
"EncoderDecoderMarginBackend requires transformers and torch"
|
|
311
|
+
) from exc
|
|
312
|
+
|
|
313
|
+
self.model_name = str((config or {}).get("model", model_id))
|
|
314
|
+
self.config = config or {}
|
|
315
|
+
self.metadata = decision_metadata(self.config)
|
|
316
|
+
if self.metadata.get("readout") != "encoder_decoder_margin":
|
|
317
|
+
raise RuntimeErrorBase(
|
|
318
|
+
"EncoderDecoderMarginBackend requires decision.readout=encoder_decoder_margin"
|
|
319
|
+
)
|
|
320
|
+
self._torch = torch
|
|
321
|
+
self._batch_size = self._positive_int("batch_size", 4)
|
|
322
|
+
limits = _mapping(self.metadata.get("limits"), "decision.limits")
|
|
323
|
+
self._query_max_length = self._positive_int_from(limits, "query", 512)
|
|
324
|
+
self._document_max_length = self._positive_int_from(limits, "document", 1024)
|
|
325
|
+
self._decoder_max_length = self._positive_int_from(limits, "decoder", 2048)
|
|
326
|
+
|
|
327
|
+
revision = self.metadata.get("revision")
|
|
328
|
+
load_kwargs: dict[str, Any] = {}
|
|
329
|
+
if isinstance(revision, str):
|
|
330
|
+
load_kwargs["revision"] = revision
|
|
331
|
+
self._tokenizer = AutoTokenizer.from_pretrained(model_id, **load_kwargs)
|
|
332
|
+
if self._tokenizer.pad_token_id is None:
|
|
333
|
+
if self._tokenizer.eos_token_id is None:
|
|
334
|
+
raise RuntimeErrorBase("tokenizer must define a pad token or an EOS token")
|
|
335
|
+
self._tokenizer.pad_token = self._tokenizer.eos_token
|
|
336
|
+
self._tokenizer.padding_side = "right"
|
|
337
|
+
|
|
338
|
+
requested_device = self.metadata.get("device", device)
|
|
339
|
+
target = (
|
|
340
|
+
"cuda"
|
|
341
|
+
if requested_device == "auto" and torch.cuda.is_available()
|
|
342
|
+
else "cpu"
|
|
343
|
+
if requested_device == "auto"
|
|
344
|
+
else requested_device
|
|
345
|
+
)
|
|
346
|
+
if not isinstance(target, str):
|
|
347
|
+
raise RuntimeErrorBase("decision.device must be a string")
|
|
348
|
+
if target.startswith("cuda") and not torch.cuda.is_available():
|
|
349
|
+
raise RuntimeErrorBase("CUDA was requested, but no CUDA device is available")
|
|
350
|
+
dtype = self._resolve_dtype(self.metadata.get("dtype"), target)
|
|
351
|
+
self._model = AutoModelForSeq2SeqLM.from_pretrained(
|
|
352
|
+
model_id, dtype=dtype, **load_kwargs
|
|
353
|
+
)
|
|
354
|
+
for parameter in self._model.parameters():
|
|
355
|
+
if parameter.is_floating_point() and parameter.dtype != dtype:
|
|
356
|
+
parameter.data = parameter.data.to(dtype=dtype)
|
|
357
|
+
self._model.to(target)
|
|
358
|
+
self._model.eval()
|
|
359
|
+
self._device = next(self._model.parameters()).device
|
|
360
|
+
|
|
361
|
+
labels = _mapping(self.metadata.get("labels"), "decision.labels")
|
|
362
|
+
self._positive_token = self._answer_token_id(
|
|
363
|
+
_template(labels.get("positive"), "decision.labels.positive")
|
|
364
|
+
)
|
|
365
|
+
self._negative_token = self._answer_token_id(
|
|
366
|
+
_template(labels.get("negative"), "decision.labels.negative")
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
encoder = _mapping(self.metadata.get("encoder"), "decision.encoder")
|
|
370
|
+
self._document_template = _template(
|
|
371
|
+
encoder.get("document_template"), "decision.encoder.document_template"
|
|
372
|
+
)
|
|
373
|
+
pooling = encoder.get("pooling", "mean_chunks")
|
|
374
|
+
if pooling not in {"mean_chunks", "none"}:
|
|
375
|
+
raise RuntimeErrorBase("decision.encoder.pooling must be mean_chunks or none")
|
|
376
|
+
self._chunk_size = (
|
|
377
|
+
self._positive_int_from(encoder, "chunk_size", 4)
|
|
378
|
+
if pooling == "mean_chunks"
|
|
379
|
+
else None
|
|
380
|
+
)
|
|
381
|
+
decoder = _mapping(self.metadata.get("decoder"), "decision.decoder")
|
|
382
|
+
self._decoder_template = _template(
|
|
383
|
+
decoder.get("template"), "decision.decoder.template"
|
|
384
|
+
)
|
|
385
|
+
self._pad_to_multiple_of = self._positive_int_from(
|
|
386
|
+
decoder, "pad_to_multiple_of", 8
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
cache = _mapping(self.metadata.get("cache", {}), "decision.cache")
|
|
390
|
+
max_mib = cache.get("max_mib", 256)
|
|
391
|
+
if not isinstance(max_mib, (int, float)) or max_mib < 0:
|
|
392
|
+
raise RuntimeErrorBase("decision.cache.max_mib must be non-negative")
|
|
393
|
+
self._cache_max_bytes = int(float(max_mib) * 1024 * 1024)
|
|
394
|
+
self._cache_bytes = 0
|
|
395
|
+
self._document_cache: OrderedDict[str, EncodedDocument] = OrderedDict()
|
|
396
|
+
|
|
397
|
+
def _positive_int(self, name: str, default: int) -> int:
|
|
398
|
+
return self._positive_int_from(self.metadata, name, default)
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _positive_int_from(values: Mapping[str, Any], name: str, default: int) -> int:
|
|
402
|
+
value = values.get(name, default)
|
|
403
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
404
|
+
raise RuntimeErrorBase(f"decision {name} must be a positive integer")
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
def _resolve_dtype(self, value: Any, device: str) -> Any:
|
|
408
|
+
torch = self._torch
|
|
409
|
+
if value is None or value == "auto":
|
|
410
|
+
return torch.bfloat16 if device.startswith("cuda") else torch.float32
|
|
411
|
+
if value in {"bfloat16", "bf16", "torch.bfloat16"}:
|
|
412
|
+
return torch.bfloat16
|
|
413
|
+
if value in {"float16", "fp16", "torch.float16"}:
|
|
414
|
+
return torch.float16
|
|
415
|
+
if value in {"float32", "fp32", "torch.float32"}:
|
|
416
|
+
return torch.float32
|
|
417
|
+
raise RuntimeErrorBase(f"unsupported decision dtype: {value!r}")
|
|
418
|
+
|
|
419
|
+
def _answer_token_id(self, answer: str) -> int:
|
|
420
|
+
token_ids = self._tokenizer(answer, add_special_tokens=False)["input_ids"]
|
|
421
|
+
if len(token_ids) != 1:
|
|
422
|
+
raise RuntimeErrorBase(
|
|
423
|
+
f"decision label {answer!r} must tokenize to exactly one token"
|
|
424
|
+
)
|
|
425
|
+
return int(token_ids[0])
|
|
426
|
+
|
|
427
|
+
def _decoder_ids(self, task: MarginTask) -> list[int]:
|
|
428
|
+
query_ids = self._tokenizer(
|
|
429
|
+
task.query,
|
|
430
|
+
add_special_tokens=False,
|
|
431
|
+
truncation=True,
|
|
432
|
+
max_length=self._query_max_length,
|
|
433
|
+
)["input_ids"]
|
|
434
|
+
truncated_query = self._tokenizer.decode(
|
|
435
|
+
query_ids,
|
|
436
|
+
skip_special_tokens=False,
|
|
437
|
+
clean_up_tokenization_spaces=False,
|
|
438
|
+
)
|
|
439
|
+
text = self._decoder_template.format(
|
|
440
|
+
query=truncated_query,
|
|
441
|
+
instruction=task.instruction,
|
|
442
|
+
)
|
|
443
|
+
return list(self._tokenizer(text, add_special_tokens=False)["input_ids"])
|
|
444
|
+
|
|
445
|
+
def _pool_chunks(self, hidden: Any, mask: Any) -> tuple[Any, Any]:
|
|
446
|
+
if self._chunk_size is None:
|
|
447
|
+
return hidden, mask
|
|
448
|
+
torch = self._torch
|
|
449
|
+
chunk_size = self._chunk_size
|
|
450
|
+
batch_size, sequence_length, hidden_size = hidden.shape
|
|
451
|
+
num_chunks = (sequence_length + chunk_size - 1) // chunk_size
|
|
452
|
+
padded_length = num_chunks * chunk_size
|
|
453
|
+
pad_length = padded_length - sequence_length
|
|
454
|
+
if pad_length:
|
|
455
|
+
hidden = torch.nn.functional.pad(hidden, (0, 0, 0, pad_length))
|
|
456
|
+
mask = torch.nn.functional.pad(mask, (0, pad_length))
|
|
457
|
+
hidden = hidden.view(batch_size, num_chunks, chunk_size, hidden_size)
|
|
458
|
+
chunk_mask = mask.view(batch_size, num_chunks, chunk_size)
|
|
459
|
+
expanded_mask = chunk_mask.unsqueeze(-1).to(hidden.dtype)
|
|
460
|
+
pooled = (hidden * expanded_mask).sum(dim=2)
|
|
461
|
+
pooled = pooled / chunk_mask.sum(dim=2).clamp(min=1).unsqueeze(-1)
|
|
462
|
+
pooled_mask = (chunk_mask.sum(dim=2) > 0).to(mask.dtype)
|
|
463
|
+
return pooled, pooled_mask
|
|
464
|
+
|
|
465
|
+
def _pad(self, rows: Sequence[list[int]], *, decoder: bool = False) -> dict[str, Any]:
|
|
466
|
+
batch = self._tokenizer.pad(
|
|
467
|
+
{"input_ids": list(rows)},
|
|
468
|
+
padding=True,
|
|
469
|
+
pad_to_multiple_of=self._pad_to_multiple_of if decoder else None,
|
|
470
|
+
return_tensors="pt",
|
|
471
|
+
)
|
|
472
|
+
return {key: value.to(self._device) for key, value in batch.items()}
|
|
473
|
+
|
|
474
|
+
def _get_encoder(self) -> Any:
|
|
475
|
+
if hasattr(self._model, "get_encoder"):
|
|
476
|
+
return self._model.get_encoder()
|
|
477
|
+
encoder = getattr(self._model, "encoder", None)
|
|
478
|
+
if encoder is None:
|
|
479
|
+
raise RuntimeErrorBase("conditional-generation model has no encoder")
|
|
480
|
+
return encoder
|
|
481
|
+
|
|
482
|
+
def _cache_put(self, key: str, value: EncodedDocument) -> None:
|
|
483
|
+
if self._cache_max_bytes == 0 or value.nbytes > self._cache_max_bytes:
|
|
484
|
+
return
|
|
485
|
+
previous = self._document_cache.pop(key, None)
|
|
486
|
+
if previous is not None:
|
|
487
|
+
self._cache_bytes -= previous.nbytes
|
|
488
|
+
self._document_cache[key] = value
|
|
489
|
+
self._cache_bytes += value.nbytes
|
|
490
|
+
while self._cache_bytes > self._cache_max_bytes:
|
|
491
|
+
_, evicted = self._document_cache.popitem(last=False)
|
|
492
|
+
self._cache_bytes -= evicted.nbytes
|
|
493
|
+
|
|
494
|
+
def _encode_documents(
|
|
495
|
+
self, documents: Mapping[str, list[int]]
|
|
496
|
+
) -> dict[str, EncodedDocument]:
|
|
497
|
+
torch = self._torch
|
|
498
|
+
result: dict[str, EncodedDocument] = {}
|
|
499
|
+
missing: list[tuple[str, list[int]]] = []
|
|
500
|
+
for document, ids in documents.items():
|
|
501
|
+
cached = self._document_cache.get(document)
|
|
502
|
+
if cached is None:
|
|
503
|
+
missing.append((document, ids))
|
|
504
|
+
else:
|
|
505
|
+
self._document_cache.move_to_end(document)
|
|
506
|
+
result[document] = cached
|
|
507
|
+
encoder = self._get_encoder()
|
|
508
|
+
for start in range(0, len(missing), self._batch_size):
|
|
509
|
+
chunk = missing[start : start + self._batch_size]
|
|
510
|
+
batch = self._pad([ids for _, ids in chunk])
|
|
511
|
+
with torch.inference_mode():
|
|
512
|
+
hidden = encoder(**batch, return_dict=True).last_hidden_state
|
|
513
|
+
mask = batch["attention_mask"]
|
|
514
|
+
hidden, mask = self._pool_chunks(hidden, mask)
|
|
515
|
+
for row, (document, _) in enumerate(chunk):
|
|
516
|
+
length = int(mask[row].sum().item())
|
|
517
|
+
encoded = EncodedDocument(
|
|
518
|
+
hidden[row, :length].detach().clone(),
|
|
519
|
+
mask[row, :length].detach().clone(),
|
|
520
|
+
)
|
|
521
|
+
result[document] = encoded
|
|
522
|
+
self._cache_put(document, encoded)
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
def _score(
|
|
526
|
+
self,
|
|
527
|
+
tasks: Sequence[MarginTask],
|
|
528
|
+
decoder_rows: Sequence[list[int]],
|
|
529
|
+
documents: Mapping[str, EncodedDocument],
|
|
530
|
+
) -> list[float]:
|
|
531
|
+
torch = self._torch
|
|
532
|
+
try:
|
|
533
|
+
from transformers.modeling_outputs import BaseModelOutput
|
|
534
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
535
|
+
raise RuntimeErrorBase("transformers modeling outputs are unavailable") from exc
|
|
536
|
+
margins: list[float] = []
|
|
537
|
+
for start in range(0, len(tasks), self._batch_size):
|
|
538
|
+
task_chunk = tasks[start : start + self._batch_size]
|
|
539
|
+
decoder_chunk = decoder_rows[start : start + self._batch_size]
|
|
540
|
+
decoder_batch = self._pad(decoder_chunk, decoder=True)
|
|
541
|
+
encoded = [documents[task.document] for task in task_chunk]
|
|
542
|
+
hidden = torch.nn.utils.rnn.pad_sequence(
|
|
543
|
+
[item.hidden for item in encoded], batch_first=True
|
|
544
|
+
)
|
|
545
|
+
mask = torch.nn.utils.rnn.pad_sequence(
|
|
546
|
+
[item.mask for item in encoded], batch_first=True
|
|
547
|
+
)
|
|
548
|
+
with torch.inference_mode():
|
|
549
|
+
output = self._model(
|
|
550
|
+
encoder_outputs=BaseModelOutput(last_hidden_state=hidden),
|
|
551
|
+
attention_mask=mask,
|
|
552
|
+
decoder_input_ids=decoder_batch["input_ids"],
|
|
553
|
+
decoder_attention_mask=decoder_batch["attention_mask"],
|
|
554
|
+
use_cache=False,
|
|
555
|
+
return_dict=True,
|
|
556
|
+
)
|
|
557
|
+
sequence_lengths = decoder_batch["attention_mask"].sum(dim=1) - 1
|
|
558
|
+
batch_indices = torch.arange(output.logits.shape[0], device=self._device)
|
|
559
|
+
last_logits = output.logits[batch_indices, sequence_lengths]
|
|
560
|
+
yes = last_logits[:, self._positive_token].float()
|
|
561
|
+
no = last_logits[:, self._negative_token].float()
|
|
562
|
+
values = yes - no
|
|
563
|
+
if not torch.isfinite(values).all():
|
|
564
|
+
raise RuntimeErrorBase("model produced non-finite decision label logits")
|
|
565
|
+
margins.extend(float(value) for value in values.cpu().tolist())
|
|
566
|
+
return margins
|
|
567
|
+
|
|
568
|
+
def decide_batch(
|
|
569
|
+
self, requests: Sequence[DecisionRequest]
|
|
570
|
+
) -> list[DecisionResponse]:
|
|
571
|
+
compiled = [compile_margin_tasks(request, self.metadata) for request in requests]
|
|
572
|
+
tasks = [task for request_tasks in compiled for task in request_tasks]
|
|
573
|
+
if not tasks:
|
|
574
|
+
return []
|
|
575
|
+
|
|
576
|
+
document_rows: dict[str, list[int]] = {}
|
|
577
|
+
decoder_rows: list[list[int]] = []
|
|
578
|
+
for task in tasks:
|
|
579
|
+
query_ids = self._tokenizer(task.query, add_special_tokens=False)["input_ids"]
|
|
580
|
+
if len(query_ids) > self._query_max_length:
|
|
581
|
+
raise RuntimeErrorBase(
|
|
582
|
+
f"state exceeds {self._query_max_length} query tokens"
|
|
583
|
+
)
|
|
584
|
+
if task.document not in document_rows:
|
|
585
|
+
document_text = self._document_template.format(document=task.document)
|
|
586
|
+
document_ids = list(
|
|
587
|
+
self._tokenizer(document_text, add_special_tokens=False)["input_ids"]
|
|
588
|
+
)
|
|
589
|
+
if len(document_ids) > self._document_max_length:
|
|
590
|
+
raise RuntimeErrorBase(
|
|
591
|
+
f"document exceeds {self._document_max_length} tokens"
|
|
592
|
+
)
|
|
593
|
+
document_rows[task.document] = document_ids
|
|
594
|
+
decoder_ids = self._decoder_ids(task)
|
|
595
|
+
padded_length = (
|
|
596
|
+
(len(decoder_ids) + self._pad_to_multiple_of - 1)
|
|
597
|
+
// self._pad_to_multiple_of
|
|
598
|
+
* self._pad_to_multiple_of
|
|
599
|
+
)
|
|
600
|
+
if padded_length > self._decoder_max_length:
|
|
601
|
+
raise RuntimeErrorBase(
|
|
602
|
+
f"decoder template exceeds {self._decoder_max_length} tokens"
|
|
603
|
+
)
|
|
604
|
+
decoder_rows.append(decoder_ids)
|
|
605
|
+
|
|
606
|
+
encoded = self._encode_documents(document_rows)
|
|
607
|
+
margins = self._score(tasks, decoder_rows, encoded)
|
|
608
|
+
responses: list[DecisionResponse] = []
|
|
609
|
+
offset = 0
|
|
610
|
+
for request, request_tasks in zip(requests, compiled, strict=True):
|
|
611
|
+
end = offset + len(request_tasks)
|
|
612
|
+
request_margins = margins[offset:end]
|
|
613
|
+
input_tokens = sum(
|
|
614
|
+
len(document_rows[task.document]) + len(decoder_rows[index])
|
|
615
|
+
for index, task in enumerate(request_tasks, start=offset)
|
|
616
|
+
)
|
|
617
|
+
responses.append(
|
|
618
|
+
DecisionResponse(
|
|
619
|
+
model=self.model_name,
|
|
620
|
+
answers=aggregate_margin_answers(
|
|
621
|
+
request,
|
|
622
|
+
request_tasks,
|
|
623
|
+
request_margins,
|
|
624
|
+
self.metadata,
|
|
625
|
+
),
|
|
626
|
+
usage=Usage(input_tokens=input_tokens),
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
offset = end
|
|
630
|
+
return responses
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Capability boundary for GLiNER2's public multilabel classifier API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .protocol import DecisionRequest, DecisionResponse, UnsupportedAnswer, Usage
|
|
9
|
+
from .runtime import DecisionRuntime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GLiNER2Runtime(DecisionRuntime):
|
|
13
|
+
"""Return explicit non-support for a model without a categorical readout.
|
|
14
|
+
|
|
15
|
+
GLiNER2's public ``classify_text`` API reports independent multilabel
|
|
16
|
+
confidences. They are not a posterior over dynamically supplied criteria,
|
|
17
|
+
so applying softmax or complement arithmetic here would measure a service
|
|
18
|
+
invented mapping rather than GLiNER2.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, model_id: str, *, config: dict[str, Any] | None = None) -> None:
|
|
22
|
+
del config
|
|
23
|
+
self.model_name = model_id
|
|
24
|
+
|
|
25
|
+
def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
|
|
26
|
+
return [
|
|
27
|
+
DecisionResponse(
|
|
28
|
+
model=self.model_name,
|
|
29
|
+
answers={
|
|
30
|
+
name: UnsupportedAnswer(
|
|
31
|
+
type="unsupported",
|
|
32
|
+
question_type=question.type,
|
|
33
|
+
supported_types=[],
|
|
34
|
+
)
|
|
35
|
+
for name, question in request.questions.items()
|
|
36
|
+
},
|
|
37
|
+
usage=Usage(),
|
|
38
|
+
)
|
|
39
|
+
for request in requests
|
|
40
|
+
]
|