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.
@@ -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
+ ]