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,144 @@
|
|
|
1
|
+
"""FastAPI application exposing the Jev-compatible endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncIterator, Sequence
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
|
+
|
|
11
|
+
from .backends import LlamaBackend, load_decision_config
|
|
12
|
+
from .batching import DecisionBatcher
|
|
13
|
+
from .protocol import DecisionRequest, DecisionResponse
|
|
14
|
+
from .registry import ModelRegistry, RegistryRuntime, build_transformers_runtime
|
|
15
|
+
from .runtime import DecisionRuntime, RuntimeErrorBase, apply_question_type_support
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _positive_batch_size(value: str) -> int:
|
|
19
|
+
try:
|
|
20
|
+
parsed = int(value)
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise ValueError("must be a positive integer") from exc
|
|
23
|
+
if parsed <= 0:
|
|
24
|
+
raise ValueError("must be a positive integer")
|
|
25
|
+
return parsed
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _model_batch_size_override(explicit: int | None = None) -> int | None:
|
|
29
|
+
if explicit is not None:
|
|
30
|
+
if isinstance(explicit, bool) or explicit <= 0:
|
|
31
|
+
raise RuntimeErrorBase("model batch size override must be a positive integer")
|
|
32
|
+
return explicit
|
|
33
|
+
configured = os.environ.get("DECISION_MODEL_BATCH_SIZE")
|
|
34
|
+
if configured is None:
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
return _positive_batch_size(configured)
|
|
38
|
+
except ValueError as exc:
|
|
39
|
+
raise RuntimeErrorBase(
|
|
40
|
+
"DECISION_MODEL_BATCH_SIZE must be a positive integer"
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_runtime(*, model_batch_size: int | None = None) -> DecisionRuntime:
|
|
45
|
+
model_batch_size = _model_batch_size_override(model_batch_size)
|
|
46
|
+
registry_path = os.environ.get("DECISION_REGISTRY")
|
|
47
|
+
if registry_path:
|
|
48
|
+
return RegistryRuntime(
|
|
49
|
+
ModelRegistry.from_file(registry_path),
|
|
50
|
+
model_batch_size=model_batch_size,
|
|
51
|
+
)
|
|
52
|
+
explicit_model = os.environ.get("DECISION_MODEL_ID") or os.environ.get(
|
|
53
|
+
"DECISION_MODEL_PATH"
|
|
54
|
+
)
|
|
55
|
+
if not explicit_model and "DECISION_BACKEND" not in os.environ:
|
|
56
|
+
return RegistryRuntime(
|
|
57
|
+
ModelRegistry.from_builtin(),
|
|
58
|
+
model_batch_size=model_batch_size,
|
|
59
|
+
)
|
|
60
|
+
backend = os.environ.get("DECISION_BACKEND", "transformers").lower()
|
|
61
|
+
config_path = os.environ.get("DECISION_CONFIG")
|
|
62
|
+
config = load_decision_config(config_path) if config_path else {}
|
|
63
|
+
if backend == "llama":
|
|
64
|
+
model_path = os.environ.get("DECISION_MODEL_PATH")
|
|
65
|
+
if not model_path:
|
|
66
|
+
raise RuntimeErrorBase("DECISION_MODEL_PATH is required for the llama backend")
|
|
67
|
+
return apply_question_type_support(
|
|
68
|
+
LlamaBackend(model_path, config=config), config
|
|
69
|
+
)
|
|
70
|
+
if backend == "transformers":
|
|
71
|
+
model_id = os.environ.get("DECISION_MODEL_ID")
|
|
72
|
+
if not model_id:
|
|
73
|
+
raise RuntimeErrorBase("DECISION_MODEL_ID is required for the transformers backend")
|
|
74
|
+
return apply_question_type_support(
|
|
75
|
+
build_transformers_runtime(
|
|
76
|
+
model_id,
|
|
77
|
+
config,
|
|
78
|
+
batch_size_override=model_batch_size,
|
|
79
|
+
),
|
|
80
|
+
config,
|
|
81
|
+
)
|
|
82
|
+
raise RuntimeErrorBase(f"unknown DECISION_BACKEND: {backend}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_app(
|
|
86
|
+
runtime: DecisionRuntime | None = None,
|
|
87
|
+
*,
|
|
88
|
+
model_batch_size: int | None = None,
|
|
89
|
+
) -> FastAPI:
|
|
90
|
+
selected_runtime = runtime or build_runtime(model_batch_size=model_batch_size)
|
|
91
|
+
batcher = DecisionBatcher(
|
|
92
|
+
selected_runtime,
|
|
93
|
+
max_batch_size=int(os.environ.get("DECISION_MAX_BATCH_SIZE", "16")),
|
|
94
|
+
wait_ms=int(os.environ.get("DECISION_BATCH_WAIT_MS", "5")),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@asynccontextmanager
|
|
98
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
99
|
+
await batcher.start()
|
|
100
|
+
yield
|
|
101
|
+
await batcher.close()
|
|
102
|
+
|
|
103
|
+
app = FastAPI(title="jev-compatible-server", lifespan=lifespan)
|
|
104
|
+
|
|
105
|
+
@app.get("/health")
|
|
106
|
+
async def health() -> dict[str, str]:
|
|
107
|
+
return {"status": "ok", "model": selected_runtime.model_name}
|
|
108
|
+
|
|
109
|
+
@app.post("/v1/systemone", response_model=DecisionResponse)
|
|
110
|
+
@app.post("/systemone", response_model=DecisionResponse)
|
|
111
|
+
async def system_one(request: DecisionRequest) -> DecisionResponse:
|
|
112
|
+
try:
|
|
113
|
+
return await batcher.submit(request)
|
|
114
|
+
except RuntimeErrorBase as exc:
|
|
115
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise HTTPException(status_code=500, detail="decision inference failed") from exc
|
|
118
|
+
|
|
119
|
+
return app
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
app: FastAPI | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
126
|
+
import argparse
|
|
127
|
+
|
|
128
|
+
import uvicorn
|
|
129
|
+
|
|
130
|
+
parser = argparse.ArgumentParser(description="Serve a Jev-compatible decision API")
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--model-batch-size",
|
|
133
|
+
type=_positive_batch_size,
|
|
134
|
+
help=(
|
|
135
|
+
"override decision.batch_size for Transformers model forward passes; "
|
|
136
|
+
"defaults to the selected model recipe"
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
args = parser.parse_args(argv)
|
|
140
|
+
uvicorn.run(
|
|
141
|
+
create_app(model_batch_size=args.model_batch_size),
|
|
142
|
+
host="0.0.0.0",
|
|
143
|
+
port=8000,
|
|
144
|
+
)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Built-in llama.cpp and Transformers adapters.
|
|
2
|
+
|
|
3
|
+
Both adapters consume the same model metadata shape. A model can therefore be
|
|
4
|
+
added by publishing metadata and weights, without adding a service branch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from .protocol import DecisionRequest, DecisionResponse, NoulQuestion, Usage
|
|
16
|
+
from .runtime import RuntimeErrorBase, TokenLogitRuntime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_decision_config(path: str | Path) -> dict[str, Any]:
|
|
20
|
+
value = json.loads(Path(path).read_text())
|
|
21
|
+
if not isinstance(value, dict):
|
|
22
|
+
raise RuntimeErrorBase("decision config must be a JSON object")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LlamaBackend(TokenLogitRuntime):
|
|
27
|
+
"""llama-cpp-python adapter for GGUF token-logit decision models."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
model_path: str,
|
|
32
|
+
*,
|
|
33
|
+
config: dict[str, Any] | None = None,
|
|
34
|
+
n_ctx: int = 4096,
|
|
35
|
+
n_gpu_layers: int = -1,
|
|
36
|
+
):
|
|
37
|
+
try:
|
|
38
|
+
from llama_cpp import Llama # type: ignore[import-not-found]
|
|
39
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
40
|
+
raise RuntimeErrorBase(
|
|
41
|
+
"LlamaBackend requires the 'llama' optional dependency"
|
|
42
|
+
) from exc
|
|
43
|
+
self._llama: Any = Llama(
|
|
44
|
+
model_path=model_path,
|
|
45
|
+
n_ctx=n_ctx,
|
|
46
|
+
n_gpu_layers=n_gpu_layers,
|
|
47
|
+
logits_all=True,
|
|
48
|
+
verbose=False,
|
|
49
|
+
)
|
|
50
|
+
metadata = getattr(self._llama, "metadata", {})
|
|
51
|
+
if callable(metadata):
|
|
52
|
+
metadata = metadata()
|
|
53
|
+
merged_config = dict(metadata) if isinstance(metadata, dict) else {}
|
|
54
|
+
merged_config.update(config or {})
|
|
55
|
+
super().__init__(model_name=str(merged_config.get("model", model_path)), config=merged_config)
|
|
56
|
+
|
|
57
|
+
def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
|
|
58
|
+
results: list[DecisionResponse] = []
|
|
59
|
+
for request in requests:
|
|
60
|
+
answers: dict[str, Any] = {}
|
|
61
|
+
input_tokens = 0
|
|
62
|
+
for name, question in request.questions.items():
|
|
63
|
+
prompt = self._prompt(request, name, question)
|
|
64
|
+
encoded = self._llama.tokenize(prompt.encode("utf-8"), add_bos=True)
|
|
65
|
+
self._llama.reset()
|
|
66
|
+
self._llama.eval(encoded)
|
|
67
|
+
input_tokens += len(encoded)
|
|
68
|
+
row = self._llama.scores[-1]
|
|
69
|
+
logits = {self._token_id(label): float(row[self._token_id(label)]) for label in self._labels(question)}
|
|
70
|
+
answers[name] = self._answer(question, logits)
|
|
71
|
+
results.append(DecisionResponse(model=self.model_name, answers=answers, usage=Usage(input_tokens=input_tokens)))
|
|
72
|
+
return results
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _labels(question: Any) -> list[str]:
|
|
76
|
+
if hasattr(question, "criteria") and isinstance(question.criteria, dict):
|
|
77
|
+
return list(question.criteria)
|
|
78
|
+
if hasattr(question, "criteria") and isinstance(question.criteria, list):
|
|
79
|
+
return [str(index) for index in range(len(question.criteria))]
|
|
80
|
+
return ["true", "false"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TransformersBackend(TokenLogitRuntime):
|
|
84
|
+
"""Transformers adapter for causal token-logit decision models.
|
|
85
|
+
|
|
86
|
+
Scalar/pointer models can use the same service by providing a future
|
|
87
|
+
readout adapter; this built-in path intentionally handles causal logits.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
model_id: str,
|
|
93
|
+
*,
|
|
94
|
+
config: dict[str, Any] | None = None,
|
|
95
|
+
device: str = "auto",
|
|
96
|
+
):
|
|
97
|
+
try:
|
|
98
|
+
import torch
|
|
99
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
100
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
101
|
+
raise RuntimeErrorBase(
|
|
102
|
+
"TransformersBackend requires the 'transformers' optional dependency"
|
|
103
|
+
) from exc
|
|
104
|
+
super().__init__(model_name=str((config or {}).get("model", model_id)), config=config)
|
|
105
|
+
self._torch = torch
|
|
106
|
+
self._tokenizer = AutoTokenizer.from_pretrained(model_id)
|
|
107
|
+
self._model = AutoModelForCausalLM.from_pretrained(model_id)
|
|
108
|
+
model_metadata = self._model.config.to_dict()
|
|
109
|
+
merged_config = dict(model_metadata)
|
|
110
|
+
merged_config.update(config or {})
|
|
111
|
+
self.config = merged_config
|
|
112
|
+
if device != "auto":
|
|
113
|
+
self._model.to(device)
|
|
114
|
+
elif torch.cuda.is_available():
|
|
115
|
+
self._model.to("cuda")
|
|
116
|
+
self._model.eval()
|
|
117
|
+
|
|
118
|
+
def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
|
|
119
|
+
prompts: list[tuple[int, str, Any]] = []
|
|
120
|
+
for request_index, request in enumerate(requests):
|
|
121
|
+
for name, question in request.questions.items():
|
|
122
|
+
prompts.append((request_index, name, question))
|
|
123
|
+
encoded = self._tokenizer(
|
|
124
|
+
[self._prompt(requests[index], name, question) for index, name, question in prompts],
|
|
125
|
+
return_tensors="pt",
|
|
126
|
+
padding=True,
|
|
127
|
+
truncation=True,
|
|
128
|
+
)
|
|
129
|
+
device = next(self._model.parameters()).device
|
|
130
|
+
encoded = {key: value.to(device) for key, value in encoded.items()}
|
|
131
|
+
with self._torch.inference_mode():
|
|
132
|
+
output = self._model(**encoded)
|
|
133
|
+
logits = output.logits[:, -1, :]
|
|
134
|
+
answers: list[dict[str, Any]] = [{} for _ in requests]
|
|
135
|
+
for row, (request_index, name, question) in enumerate(prompts):
|
|
136
|
+
labels = LlamaBackend._labels(question)
|
|
137
|
+
values = {self._token_id(label): float(logits[row, self._token_id(label)].item()) for label in labels}
|
|
138
|
+
answers[request_index][name] = self._answer(question, values)
|
|
139
|
+
return [
|
|
140
|
+
DecisionResponse(model=self.model_name, answers=answer, usage=Usage())
|
|
141
|
+
for answer in answers
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PointerTransformersBackend(TokenLogitRuntime):
|
|
146
|
+
"""Owned implementation of the Qwen + LoRA + pointer-head pattern.
|
|
147
|
+
|
|
148
|
+
The model repository is only a source of weights. Packing delimiters,
|
|
149
|
+
masking, LoRA location, and pointer tensors come from service-owned
|
|
150
|
+
registry metadata, so this does not import a model author's serving code.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, model_id: str, *, config: dict[str, Any] | None = None, device: str = "auto"):
|
|
154
|
+
try:
|
|
155
|
+
import torch
|
|
156
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
157
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
158
|
+
raise RuntimeErrorBase("PointerTransformersBackend requires transformers and torch") from exc
|
|
159
|
+
super().__init__(model_name=str((config or {}).get("model", model_id)), config=config)
|
|
160
|
+
self._torch = torch
|
|
161
|
+
backbone = self.config.get("decision.backbone") or self.config.get("backbone") or model_id
|
|
162
|
+
self._tokenizer = AutoTokenizer.from_pretrained(backbone)
|
|
163
|
+
load_kwargs: dict[str, Any] = {}
|
|
164
|
+
if torch.cuda.is_available():
|
|
165
|
+
load_kwargs["torch_dtype"] = torch.bfloat16
|
|
166
|
+
# Pointer models provide a custom 4-D block-causal mask; eager attention
|
|
167
|
+
# preserves that mask contract across Transformers releases.
|
|
168
|
+
load_kwargs["attn_implementation"] = "eager"
|
|
169
|
+
full_model = AutoModelForCausalLM.from_pretrained(backbone, **load_kwargs)
|
|
170
|
+
self._model = getattr(full_model, "model", full_model)
|
|
171
|
+
adapter = self.config.get("decision.adapter") or self.config.get("adapter")
|
|
172
|
+
if adapter == "auto":
|
|
173
|
+
adapter = model_id
|
|
174
|
+
if adapter:
|
|
175
|
+
try:
|
|
176
|
+
from peft import PeftModel
|
|
177
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
178
|
+
raise RuntimeErrorBase("pointer-head LoRA models require peft") from exc
|
|
179
|
+
self._model = PeftModel.from_pretrained(self._model, adapter)
|
|
180
|
+
target = "cuda" if device == "auto" and torch.cuda.is_available() else device
|
|
181
|
+
if target != "auto":
|
|
182
|
+
self._model.to(target)
|
|
183
|
+
self._model.eval()
|
|
184
|
+
self._device = next(self._model.parameters()).device
|
|
185
|
+
self._load_pointer_head()
|
|
186
|
+
|
|
187
|
+
def _load_pointer_head(self) -> None:
|
|
188
|
+
import torch
|
|
189
|
+
|
|
190
|
+
head_path = self.config.get("decision.head_path") or self.config.get("head_path")
|
|
191
|
+
if not isinstance(head_path, str):
|
|
192
|
+
raise RuntimeErrorBase("pointer_head requires decision.head_path")
|
|
193
|
+
if not __import__("os").path.exists(head_path):
|
|
194
|
+
repo = self.config.get("decision.head_repo") or self.config.get("head_repo") or self.config.get("model")
|
|
195
|
+
if not isinstance(repo, str):
|
|
196
|
+
raise RuntimeErrorBase("relative pointer head path requires decision.head_repo")
|
|
197
|
+
try:
|
|
198
|
+
from huggingface_hub import hf_hub_download
|
|
199
|
+
|
|
200
|
+
head_path = hf_hub_download(repo_id=repo, filename=head_path)
|
|
201
|
+
except Exception as exc: # pragma: no cover - network/model dependent
|
|
202
|
+
raise RuntimeErrorBase(f"could not resolve pointer head {head_path!r} from {repo!r}") from exc
|
|
203
|
+
payload = torch.load(head_path, map_location="cpu", weights_only=True)
|
|
204
|
+
state = payload.get("state_dict", payload) if isinstance(payload, dict) else payload
|
|
205
|
+
if isinstance(state, dict) and isinstance(state.get("head"), dict):
|
|
206
|
+
state = state["head"]
|
|
207
|
+
if not isinstance(state, dict):
|
|
208
|
+
raise RuntimeErrorBase("pointer head checkpoint must contain a state dict")
|
|
209
|
+
q_weight = self._find_tensor(state, ("q.weight", "query.weight"))
|
|
210
|
+
k_weight = self._find_tensor(state, ("k.weight", "key.weight"))
|
|
211
|
+
q_bias = self._find_tensor(state, ("q.bias", "query.bias"), required=False)
|
|
212
|
+
k_bias = self._find_tensor(state, ("k.bias", "key.bias"), required=False)
|
|
213
|
+
if q_weight is None or k_weight is None:
|
|
214
|
+
raise RuntimeErrorBase("pointer head checkpoint needs q/query and k/key weights")
|
|
215
|
+
self._q_weight = q_weight.to(self._device)
|
|
216
|
+
self._k_weight = k_weight.to(self._device)
|
|
217
|
+
self._q_bias = q_bias.to(self._device) if q_bias is not None else None
|
|
218
|
+
self._k_bias = k_bias.to(self._device) if k_bias is not None else None
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _find_tensor(
|
|
222
|
+
state: dict[str, Any],
|
|
223
|
+
suffixes: tuple[str, ...],
|
|
224
|
+
*,
|
|
225
|
+
required: bool = True,
|
|
226
|
+
) -> Any | None:
|
|
227
|
+
for key, value in state.items():
|
|
228
|
+
if isinstance(key, str) and any(key.endswith(suffix) for suffix in suffixes):
|
|
229
|
+
return value
|
|
230
|
+
if required:
|
|
231
|
+
return None
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def _packing(self) -> dict[str, Any]:
|
|
235
|
+
packing = self.config.get("decision.packing", self.config.get("packing", {}))
|
|
236
|
+
if not isinstance(packing, dict):
|
|
237
|
+
raise RuntimeErrorBase("decision.packing must be an object")
|
|
238
|
+
required = ("state_token", "question_token", "option_start", "option_end", "decision_token")
|
|
239
|
+
missing = [key for key in required if not isinstance(packing.get(key), str)]
|
|
240
|
+
if missing:
|
|
241
|
+
raise RuntimeErrorBase(f"pointer_head packing metadata missing: {', '.join(missing)}")
|
|
242
|
+
return packing
|
|
243
|
+
|
|
244
|
+
def _encode(self, request: DecisionRequest, question: Any) -> dict[str, Any]:
|
|
245
|
+
packing = self._packing()
|
|
246
|
+
tok = self._tokenizer
|
|
247
|
+
|
|
248
|
+
def user(value: Any) -> list[int]:
|
|
249
|
+
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
250
|
+
return cast(list[int], tok(text, add_special_tokens=False).input_ids)
|
|
251
|
+
|
|
252
|
+
def special(name: str) -> int:
|
|
253
|
+
token = packing[name]
|
|
254
|
+
token_id = tok.convert_tokens_to_ids(token)
|
|
255
|
+
if token_id is None or token_id < 0:
|
|
256
|
+
raise RuntimeErrorBase(f"packing token is not in tokenizer vocabulary: {token}")
|
|
257
|
+
return int(token_id)
|
|
258
|
+
|
|
259
|
+
state = [special("state_token"), *user(request.state)]
|
|
260
|
+
instruction = [special("question_token"), *user(question.instructions)]
|
|
261
|
+
if isinstance(question, NoulQuestion):
|
|
262
|
+
criteria: list[Any] = ["false", "true"]
|
|
263
|
+
elif isinstance(question.criteria, dict):
|
|
264
|
+
criteria = [f"{key}: {value}" if value else key for key, value in question.criteria.items()]
|
|
265
|
+
else:
|
|
266
|
+
criteria = list(question.criteria)
|
|
267
|
+
spans = [[special("option_start"), *user(value), special("option_end")] for value in criteria]
|
|
268
|
+
ids = state + instruction
|
|
269
|
+
seg = [0] * len(state) + [1] * len(instruction)
|
|
270
|
+
opt = [-1] * len(ids)
|
|
271
|
+
ends: list[int] = []
|
|
272
|
+
for option_index, span in enumerate(spans):
|
|
273
|
+
start = len(ids)
|
|
274
|
+
ids.extend(span)
|
|
275
|
+
seg.extend([1] * len(span))
|
|
276
|
+
opt.extend([option_index] * len(span))
|
|
277
|
+
ends.append(start + len(span) - 1)
|
|
278
|
+
decision_index = len(ids)
|
|
279
|
+
ids.append(special("decision_token")); seg.append(1); opt.append(-2)
|
|
280
|
+
return {"ids": ids, "seg": seg, "opt": opt, "ends": ends, "decision": decision_index}
|
|
281
|
+
|
|
282
|
+
def _hidden_batch(self, encodings: list[dict[str, Any]]) -> Any:
|
|
283
|
+
torch = self._torch
|
|
284
|
+
length = max(len(item["ids"]) for item in encodings)
|
|
285
|
+
pad_id = self._tokenizer.pad_token_id or 0
|
|
286
|
+
ids = torch.full((len(encodings), length), pad_id, dtype=torch.long, device=self._device)
|
|
287
|
+
mask = torch.full((len(encodings), 1, length, length), torch.finfo(torch.float32).min, device=self._device)
|
|
288
|
+
positions = torch.zeros((len(encodings), length), dtype=torch.long, device=self._device)
|
|
289
|
+
for row, item in enumerate(encodings):
|
|
290
|
+
size = len(item["ids"]); ids[row, :size] = torch.tensor(item["ids"], device=self._device)
|
|
291
|
+
positions[row, :size] = torch.arange(size, device=self._device)
|
|
292
|
+
seg = torch.tensor(item["seg"], device=self._device)
|
|
293
|
+
opt = torch.tensor(item["opt"], device=self._device)
|
|
294
|
+
causal = torch.tril(torch.ones((size, size), dtype=torch.bool, device=self._device))
|
|
295
|
+
allowed = causal & ((seg[None, :] == 0) | (seg[None, :] == seg[:, None]))
|
|
296
|
+
option_keys = opt[None, :] >= 0
|
|
297
|
+
decide = opt[:, None] == -2
|
|
298
|
+
allowed &= (~option_keys | decide | (opt[None, :] == opt[:, None]))
|
|
299
|
+
mask[row, 0, :size, :size] = torch.where(allowed, 0.0, torch.finfo(torch.float32).min)
|
|
300
|
+
with torch.inference_mode():
|
|
301
|
+
return self._model(input_ids=ids, position_ids=positions, attention_mask=mask).last_hidden_state.float()
|
|
302
|
+
|
|
303
|
+
def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
|
|
304
|
+
flattened: list[tuple[int, str, Any, dict[str, Any]]] = []
|
|
305
|
+
for request_index, request in enumerate(requests):
|
|
306
|
+
for name, question in request.questions.items():
|
|
307
|
+
flattened.append((request_index, name, question, self._encode(request, question)))
|
|
308
|
+
hidden = self._hidden_batch([item[3] for item in flattened])
|
|
309
|
+
answers: list[dict[str, Any]] = [{} for _ in requests]
|
|
310
|
+
for row, (request_index, name, question, encoding) in enumerate(flattened):
|
|
311
|
+
query = hidden[row, encoding["decision"]]
|
|
312
|
+
options = hidden[row, encoding["ends"]]
|
|
313
|
+
q = self._q_weight @ query + (self._q_bias if self._q_bias is not None else 0)
|
|
314
|
+
k = options @ self._k_weight.T
|
|
315
|
+
scores = (k @ q) / math.sqrt(q.shape[-1])
|
|
316
|
+
labels = ["false", "true"] if isinstance(question, NoulQuestion) else self._labels_for_question(question)
|
|
317
|
+
answers[request_index][name] = self.answer_from_label_scores(
|
|
318
|
+
question, {label: float(value) for label, value in zip(labels, scores.tolist(), strict=True)}
|
|
319
|
+
)
|
|
320
|
+
return [DecisionResponse(model=self.model_name, answers=answer, usage=Usage()) for answer in answers]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Small dynamic microbatcher for Jev-compatible requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .protocol import DecisionRequest, DecisionResponse
|
|
9
|
+
from .runtime import DecisionRuntime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class _Pending:
|
|
14
|
+
request: DecisionRequest
|
|
15
|
+
future: asyncio.Future[DecisionResponse]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DecisionBatcher:
|
|
19
|
+
"""Collects concurrent requests for a bounded interval and runs one batch."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, runtime: DecisionRuntime, *, max_batch_size: int = 16, wait_ms: int = 5):
|
|
22
|
+
self.runtime = runtime
|
|
23
|
+
self.max_batch_size = max_batch_size
|
|
24
|
+
self.wait_seconds = wait_ms / 1000
|
|
25
|
+
self._queue: asyncio.Queue[_Pending] = asyncio.Queue()
|
|
26
|
+
self._worker: asyncio.Task[None] | None = None
|
|
27
|
+
|
|
28
|
+
async def start(self) -> None:
|
|
29
|
+
if self._worker is None:
|
|
30
|
+
self._worker = asyncio.create_task(self._run())
|
|
31
|
+
|
|
32
|
+
async def close(self) -> None:
|
|
33
|
+
if self._worker is not None:
|
|
34
|
+
self._worker.cancel()
|
|
35
|
+
await asyncio.gather(self._worker, return_exceptions=True)
|
|
36
|
+
self._worker = None
|
|
37
|
+
|
|
38
|
+
async def submit(self, request: DecisionRequest) -> DecisionResponse:
|
|
39
|
+
if self._worker is None:
|
|
40
|
+
await self.start()
|
|
41
|
+
future: asyncio.Future[DecisionResponse] = asyncio.get_running_loop().create_future()
|
|
42
|
+
await self._queue.put(_Pending(request, future))
|
|
43
|
+
return await future
|
|
44
|
+
|
|
45
|
+
async def _run(self) -> None:
|
|
46
|
+
while True:
|
|
47
|
+
first = await self._queue.get()
|
|
48
|
+
batch = [first]
|
|
49
|
+
deadline = asyncio.get_running_loop().time() + self.wait_seconds
|
|
50
|
+
while len(batch) < self.max_batch_size:
|
|
51
|
+
timeout = deadline - asyncio.get_running_loop().time()
|
|
52
|
+
if timeout <= 0:
|
|
53
|
+
break
|
|
54
|
+
try:
|
|
55
|
+
batch.append(await asyncio.wait_for(self._queue.get(), timeout))
|
|
56
|
+
except TimeoutError:
|
|
57
|
+
break
|
|
58
|
+
try:
|
|
59
|
+
responses = await asyncio.to_thread(
|
|
60
|
+
self.runtime.decide_batch, [item.request for item in batch]
|
|
61
|
+
)
|
|
62
|
+
if len(responses) != len(batch):
|
|
63
|
+
raise RuntimeError("runtime returned the wrong batch length")
|
|
64
|
+
for item, response in zip(batch, responses, strict=True):
|
|
65
|
+
item.future.set_result(response)
|
|
66
|
+
except Exception as exc: # noqa: BLE001 - propagate backend failures to every future
|
|
67
|
+
for item in batch:
|
|
68
|
+
item.future.set_exception(exc)
|