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,384 @@
|
|
|
1
|
+
"""Generic final-hidden-state probe readout for Transformers checkpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .encoder_decoder import (
|
|
10
|
+
MarginTask,
|
|
11
|
+
_mapping,
|
|
12
|
+
_template,
|
|
13
|
+
aggregate_margin_answers,
|
|
14
|
+
compile_margin_tasks,
|
|
15
|
+
decision_metadata,
|
|
16
|
+
render_content,
|
|
17
|
+
)
|
|
18
|
+
from .protocol import DecisionRequest, DecisionResponse, NoulAnswer, Usage
|
|
19
|
+
from .runtime import DecisionRuntime, RuntimeErrorBase
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _state_path(state: Any, path: str) -> Any:
|
|
23
|
+
value = state
|
|
24
|
+
for part in path.split("."):
|
|
25
|
+
if not isinstance(value, Mapping) or part not in value:
|
|
26
|
+
raise RuntimeErrorBase(f"decision input state path is missing: {path}")
|
|
27
|
+
value = value[part]
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_probe_input(request: DecisionRequest, metadata: Mapping[str, Any]) -> str:
|
|
32
|
+
"""Render a shared-state probe input entirely from declarative metadata."""
|
|
33
|
+
|
|
34
|
+
input_config = _mapping(metadata.get("input"), "decision.input")
|
|
35
|
+
template = _template(input_config.get("template"), "decision.input.template")
|
|
36
|
+
fields = _mapping(input_config.get("fields", {}), "decision.input.fields")
|
|
37
|
+
values: dict[str, str] = {"state": render_content(request.state)}
|
|
38
|
+
for name, path in fields.items():
|
|
39
|
+
if not isinstance(name, str) or not isinstance(path, str):
|
|
40
|
+
raise RuntimeErrorBase("decision.input.fields must map strings to state paths")
|
|
41
|
+
values[name] = render_content(_state_path(request.state, path))
|
|
42
|
+
try:
|
|
43
|
+
return template.format_map(values)
|
|
44
|
+
except KeyError as exc:
|
|
45
|
+
raise RuntimeErrorBase(
|
|
46
|
+
f"decision.input.template references an undeclared field: {exc.args[0]}"
|
|
47
|
+
) from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_probe_task(task: MarginTask, metadata: Mapping[str, Any]) -> str:
|
|
51
|
+
"""Render one candidate as an external answer for a verifier probe."""
|
|
52
|
+
|
|
53
|
+
input_config = _mapping(metadata.get("input"), "decision.input")
|
|
54
|
+
template = _template(input_config.get("template"), "decision.input.template")
|
|
55
|
+
try:
|
|
56
|
+
return template.format_map(
|
|
57
|
+
{
|
|
58
|
+
"query": task.query,
|
|
59
|
+
"instructions": task.instruction,
|
|
60
|
+
"candidate": task.document,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
except KeyError as exc:
|
|
64
|
+
raise RuntimeErrorBase(
|
|
65
|
+
f"decision.input.template references an undeclared candidate field: "
|
|
66
|
+
f"{exc.args[0]}"
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HiddenStateProbeBackend(DecisionRuntime):
|
|
71
|
+
"""Apply a configured linear or RBF probe to one hidden state per request."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
model_id: str,
|
|
76
|
+
*,
|
|
77
|
+
config: dict[str, Any] | None = None,
|
|
78
|
+
device: str = "auto",
|
|
79
|
+
) -> None:
|
|
80
|
+
try:
|
|
81
|
+
import numpy as np
|
|
82
|
+
import torch
|
|
83
|
+
from huggingface_hub import hf_hub_download
|
|
84
|
+
from transformers import AutoModel, AutoTokenizer
|
|
85
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
86
|
+
raise RuntimeErrorBase(
|
|
87
|
+
"HiddenStateProbeBackend requires the 'transformers' optional dependency"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
self.model_name = str((config or {}).get("model", model_id))
|
|
91
|
+
self.config = config or {}
|
|
92
|
+
self.metadata = decision_metadata(self.config)
|
|
93
|
+
if self.metadata.get("readout") != "hidden_state_probe":
|
|
94
|
+
raise RuntimeErrorBase(
|
|
95
|
+
"HiddenStateProbeBackend requires decision.readout=hidden_state_probe"
|
|
96
|
+
)
|
|
97
|
+
self._np = np
|
|
98
|
+
self._torch = torch
|
|
99
|
+
|
|
100
|
+
loader = _mapping(self.metadata.get("loader", {}), "decision.loader")
|
|
101
|
+
revision = loader.get("revision")
|
|
102
|
+
if revision is not None and not isinstance(revision, str):
|
|
103
|
+
raise RuntimeErrorBase("decision.loader.revision must be a string")
|
|
104
|
+
trust_remote_code = loader.get("trust_remote_code", False)
|
|
105
|
+
if not isinstance(trust_remote_code, bool):
|
|
106
|
+
raise RuntimeErrorBase("decision.loader.trust_remote_code must be boolean")
|
|
107
|
+
tokenizer_id = loader.get("tokenizer", model_id)
|
|
108
|
+
if not isinstance(tokenizer_id, str):
|
|
109
|
+
raise RuntimeErrorBase("decision.loader.tokenizer must be a string")
|
|
110
|
+
common_kwargs: dict[str, Any] = {"trust_remote_code": trust_remote_code}
|
|
111
|
+
if revision is not None:
|
|
112
|
+
common_kwargs["revision"] = revision
|
|
113
|
+
self._tokenizer = AutoTokenizer.from_pretrained(tokenizer_id, **common_kwargs)
|
|
114
|
+
if self._tokenizer.pad_token_id is None:
|
|
115
|
+
if self._tokenizer.eos_token_id is None:
|
|
116
|
+
raise RuntimeErrorBase("tokenizer must define a pad token or an EOS token")
|
|
117
|
+
self._tokenizer.pad_token = self._tokenizer.eos_token
|
|
118
|
+
self._tokenizer.padding_side = "right"
|
|
119
|
+
|
|
120
|
+
requested_device = self.metadata.get("device", device)
|
|
121
|
+
target = (
|
|
122
|
+
"cuda"
|
|
123
|
+
if requested_device == "auto" and torch.cuda.is_available()
|
|
124
|
+
else "cpu"
|
|
125
|
+
if requested_device == "auto"
|
|
126
|
+
else requested_device
|
|
127
|
+
)
|
|
128
|
+
if not isinstance(target, str):
|
|
129
|
+
raise RuntimeErrorBase("decision.device must be a string")
|
|
130
|
+
if target.startswith("cuda") and not torch.cuda.is_available():
|
|
131
|
+
raise RuntimeErrorBase("CUDA was requested, but no CUDA device is available")
|
|
132
|
+
dtype = self._resolve_dtype(self.metadata.get("dtype"), target)
|
|
133
|
+
model_kwargs = dict(common_kwargs)
|
|
134
|
+
model_kwargs["dtype"] = dtype
|
|
135
|
+
model_kwargs["low_cpu_mem_usage"] = True
|
|
136
|
+
attention = loader.get("attn_implementation", "sdpa")
|
|
137
|
+
if attention is not None:
|
|
138
|
+
if not isinstance(attention, str):
|
|
139
|
+
raise RuntimeErrorBase(
|
|
140
|
+
"decision.loader.attn_implementation must be a string or null"
|
|
141
|
+
)
|
|
142
|
+
model_kwargs["attn_implementation"] = attention
|
|
143
|
+
device_map = loader.get("device_map")
|
|
144
|
+
if device_map is not None:
|
|
145
|
+
if not isinstance(device_map, str):
|
|
146
|
+
raise RuntimeErrorBase("decision.loader.device_map must be a string")
|
|
147
|
+
model_kwargs["device_map"] = device_map
|
|
148
|
+
self._model = AutoModel.from_pretrained(model_id, **model_kwargs)
|
|
149
|
+
if device_map is None:
|
|
150
|
+
self._model.to(target)
|
|
151
|
+
self._model.eval()
|
|
152
|
+
|
|
153
|
+
input_config = _mapping(self.metadata.get("input"), "decision.input")
|
|
154
|
+
self._input_mode = input_config.get("mode", "shared_state")
|
|
155
|
+
if self._input_mode not in {"shared_state", "candidate_tasks"}:
|
|
156
|
+
raise RuntimeErrorBase(
|
|
157
|
+
"decision.input.mode must be shared_state or candidate_tasks"
|
|
158
|
+
)
|
|
159
|
+
max_length = input_config.get("max_length", 2048)
|
|
160
|
+
if not isinstance(max_length, int) or isinstance(max_length, bool) or max_length <= 0:
|
|
161
|
+
raise RuntimeErrorBase("decision.input.max_length must be a positive integer")
|
|
162
|
+
self._max_length = max_length
|
|
163
|
+
batch_size = self.metadata.get("batch_size", 8)
|
|
164
|
+
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size <= 0:
|
|
165
|
+
raise RuntimeErrorBase("decision.batch_size must be a positive integer")
|
|
166
|
+
self._batch_size = batch_size
|
|
167
|
+
hidden = _mapping(self.metadata.get("hidden", {}), "decision.hidden")
|
|
168
|
+
if hidden.get("token", "last_non_pad") != "last_non_pad":
|
|
169
|
+
raise RuntimeErrorBase(
|
|
170
|
+
"hidden_state_probe currently supports only hidden.token=last_non_pad"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
probe = _mapping(self.metadata.get("probe"), "decision.probe")
|
|
174
|
+
self._probe_kind = probe.get("kind")
|
|
175
|
+
if self._probe_kind not in {"linear", "rbf"}:
|
|
176
|
+
raise RuntimeErrorBase("decision.probe.kind must be linear or rbf")
|
|
177
|
+
probe_file = probe.get("file")
|
|
178
|
+
if not isinstance(probe_file, str):
|
|
179
|
+
raise RuntimeErrorBase("decision.probe.file must be a string")
|
|
180
|
+
probe_repo = probe.get("repo", model_id)
|
|
181
|
+
if probe_repo == "auto":
|
|
182
|
+
probe_repo = model_id
|
|
183
|
+
if not isinstance(probe_repo, str):
|
|
184
|
+
raise RuntimeErrorBase("decision.probe.repo must be a string")
|
|
185
|
+
probe_revision = probe.get("revision", revision)
|
|
186
|
+
if probe_revision is not None and not isinstance(probe_revision, str):
|
|
187
|
+
raise RuntimeErrorBase("decision.probe.revision must be a string")
|
|
188
|
+
local_probe = hf_hub_download(
|
|
189
|
+
repo_id=probe_repo,
|
|
190
|
+
filename=probe_file,
|
|
191
|
+
revision=probe_revision,
|
|
192
|
+
)
|
|
193
|
+
payload = np.load(local_probe, allow_pickle=False)
|
|
194
|
+
required = (
|
|
195
|
+
("w", "mu", "sd")
|
|
196
|
+
if self._probe_kind == "linear"
|
|
197
|
+
else ("anchors", "alpha", "mu", "sd", "med", "gamma")
|
|
198
|
+
)
|
|
199
|
+
missing = [name for name in required if name not in payload]
|
|
200
|
+
if missing:
|
|
201
|
+
raise RuntimeErrorBase(
|
|
202
|
+
f"decision probe is missing arrays: {', '.join(missing)}"
|
|
203
|
+
)
|
|
204
|
+
self._probe = {
|
|
205
|
+
name: torch.from_numpy(
|
|
206
|
+
np.asarray(payload[name], dtype=np.float32).copy()
|
|
207
|
+
)
|
|
208
|
+
for name in required
|
|
209
|
+
}
|
|
210
|
+
if bool((self._probe["sd"] == 0).any()):
|
|
211
|
+
raise RuntimeErrorBase("decision probe sd contains zero")
|
|
212
|
+
self._calibration: dict[str, float] | None
|
|
213
|
+
self._output = _mapping(self.metadata.get("output", {}), "decision.output")
|
|
214
|
+
transform = self._output.get("transform")
|
|
215
|
+
if self._input_mode == "shared_state" and transform not in {
|
|
216
|
+
"sigmoid",
|
|
217
|
+
"probe_calibrated_sigmoid",
|
|
218
|
+
}:
|
|
219
|
+
raise RuntimeErrorBase(
|
|
220
|
+
"shared_state probes require decision.output.transform="
|
|
221
|
+
"sigmoid or probe_calibrated_sigmoid"
|
|
222
|
+
)
|
|
223
|
+
if transform == "probe_calibrated_sigmoid":
|
|
224
|
+
calibration = ("s_mean", "s_std", "cal_A", "cal_B")
|
|
225
|
+
absent = [name for name in calibration if name not in payload]
|
|
226
|
+
if absent:
|
|
227
|
+
raise RuntimeErrorBase(
|
|
228
|
+
f"decision probe is missing calibration arrays: {', '.join(absent)}"
|
|
229
|
+
)
|
|
230
|
+
self._calibration = {
|
|
231
|
+
name: float(payload[name]) for name in calibration
|
|
232
|
+
}
|
|
233
|
+
if self._calibration["s_std"] == 0:
|
|
234
|
+
raise RuntimeErrorBase("decision probe calibration s_std is zero")
|
|
235
|
+
else:
|
|
236
|
+
self._calibration = None
|
|
237
|
+
|
|
238
|
+
def _resolve_dtype(self, value: Any, device: str) -> Any:
|
|
239
|
+
torch = self._torch
|
|
240
|
+
if value is None or value == "auto":
|
|
241
|
+
return torch.bfloat16 if device.startswith("cuda") else torch.float32
|
|
242
|
+
if value in {"bfloat16", "bf16", "torch.bfloat16"}:
|
|
243
|
+
return torch.bfloat16
|
|
244
|
+
if value in {"float16", "fp16", "torch.float16"}:
|
|
245
|
+
return torch.float16
|
|
246
|
+
if value in {"float32", "fp32", "torch.float32"}:
|
|
247
|
+
return torch.float32
|
|
248
|
+
raise RuntimeErrorBase(f"unsupported decision dtype: {value!r}")
|
|
249
|
+
|
|
250
|
+
def _input_device(self) -> Any:
|
|
251
|
+
embeddings = self._model.get_input_embeddings()
|
|
252
|
+
if embeddings is not None and hasattr(embeddings, "weight"):
|
|
253
|
+
return embeddings.weight.device
|
|
254
|
+
return next(self._model.parameters()).device
|
|
255
|
+
|
|
256
|
+
def _raw_scores(self, hidden: Any) -> Any:
|
|
257
|
+
torch = self._torch
|
|
258
|
+
probe = {name: value.to(hidden.device) for name, value in self._probe.items()}
|
|
259
|
+
standardized = (hidden.float() - probe["mu"]) / probe["sd"]
|
|
260
|
+
if self._probe_kind == "linear":
|
|
261
|
+
scores = standardized @ probe["w"]
|
|
262
|
+
else:
|
|
263
|
+
anchors = probe["anchors"]
|
|
264
|
+
squared = (
|
|
265
|
+
(anchors * anchors).sum(dim=1).unsqueeze(0)
|
|
266
|
+
+ (standardized * standardized).sum(dim=1).unsqueeze(1)
|
|
267
|
+
- 2.0 * (standardized @ anchors.T)
|
|
268
|
+
)
|
|
269
|
+
denominator = probe["med"] * probe["gamma"]
|
|
270
|
+
if not bool(torch.isfinite(denominator)) or float(denominator) <= 0:
|
|
271
|
+
raise RuntimeErrorBase("decision RBF probe has an invalid denominator")
|
|
272
|
+
kernel = torch.exp(-torch.clamp(squared, min=0.0) / denominator)
|
|
273
|
+
scores = kernel @ probe["alpha"]
|
|
274
|
+
if not bool(torch.isfinite(scores).all()):
|
|
275
|
+
raise RuntimeErrorBase("decision probe produced non-finite scores")
|
|
276
|
+
return scores
|
|
277
|
+
|
|
278
|
+
def _probabilities(self, scores: Any) -> Any:
|
|
279
|
+
torch = self._torch
|
|
280
|
+
if self._calibration is not None:
|
|
281
|
+
values = (
|
|
282
|
+
self._calibration["cal_A"]
|
|
283
|
+
* (scores - self._calibration["s_mean"])
|
|
284
|
+
/ self._calibration["s_std"]
|
|
285
|
+
+ self._calibration["cal_B"]
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
scale = self._output.get("scale", 1.0)
|
|
289
|
+
bias = self._output.get("bias", 0.0)
|
|
290
|
+
if not isinstance(scale, int | float) or not isinstance(bias, int | float):
|
|
291
|
+
raise RuntimeErrorBase("decision.output scale and bias must be numeric")
|
|
292
|
+
if not math.isfinite(float(scale)) or not math.isfinite(float(bias)):
|
|
293
|
+
raise RuntimeErrorBase("decision.output scale and bias must be finite")
|
|
294
|
+
values = scores * float(scale) + float(bias)
|
|
295
|
+
return torch.sigmoid(values)
|
|
296
|
+
|
|
297
|
+
def _score_texts(self, texts: Sequence[str]) -> tuple[list[float], list[int]]:
|
|
298
|
+
scores: list[float] = []
|
|
299
|
+
input_tokens: list[int] = []
|
|
300
|
+
torch = self._torch
|
|
301
|
+
device = self._input_device()
|
|
302
|
+
for start in range(0, len(texts), self._batch_size):
|
|
303
|
+
encoded = self._tokenizer(
|
|
304
|
+
list(texts[start : start + self._batch_size]),
|
|
305
|
+
return_tensors="pt",
|
|
306
|
+
padding=True,
|
|
307
|
+
truncation=True,
|
|
308
|
+
max_length=self._max_length,
|
|
309
|
+
)
|
|
310
|
+
encoded = {name: value.to(device) for name, value in encoded.items()}
|
|
311
|
+
with torch.inference_mode():
|
|
312
|
+
output = self._model(**encoded)
|
|
313
|
+
last_hidden = getattr(output, "last_hidden_state", None)
|
|
314
|
+
if last_hidden is None:
|
|
315
|
+
raise RuntimeErrorBase("model output has no last_hidden_state")
|
|
316
|
+
mask = encoded.get("attention_mask")
|
|
317
|
+
if mask is None:
|
|
318
|
+
raise RuntimeErrorBase("tokenizer output has no attention_mask")
|
|
319
|
+
positions = mask.sum(dim=1) - 1
|
|
320
|
+
rows = torch.arange(last_hidden.shape[0], device=last_hidden.device)
|
|
321
|
+
selected = last_hidden[rows, positions.to(last_hidden.device)]
|
|
322
|
+
scores.extend(
|
|
323
|
+
float(value)
|
|
324
|
+
for value in self._raw_scores(selected).cpu().tolist()
|
|
325
|
+
)
|
|
326
|
+
input_tokens.extend(int(value) for value in mask.sum(dim=1).cpu().tolist())
|
|
327
|
+
return scores, input_tokens
|
|
328
|
+
|
|
329
|
+
def _decide_candidate_tasks(
|
|
330
|
+
self, requests: Sequence[DecisionRequest]
|
|
331
|
+
) -> list[DecisionResponse]:
|
|
332
|
+
compiled = [compile_margin_tasks(request, self.metadata) for request in requests]
|
|
333
|
+
tasks = [task for request_tasks in compiled for task in request_tasks]
|
|
334
|
+
scores, input_tokens = self._score_texts(
|
|
335
|
+
[render_probe_task(task, self.metadata) for task in tasks]
|
|
336
|
+
)
|
|
337
|
+
responses: list[DecisionResponse] = []
|
|
338
|
+
offset = 0
|
|
339
|
+
for request, request_tasks in zip(requests, compiled, strict=True):
|
|
340
|
+
end = offset + len(request_tasks)
|
|
341
|
+
responses.append(
|
|
342
|
+
DecisionResponse(
|
|
343
|
+
model=self.model_name,
|
|
344
|
+
answers=aggregate_margin_answers(
|
|
345
|
+
request,
|
|
346
|
+
request_tasks,
|
|
347
|
+
scores[offset:end],
|
|
348
|
+
self.metadata,
|
|
349
|
+
),
|
|
350
|
+
usage=Usage(input_tokens=sum(input_tokens[offset:end])),
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
offset = end
|
|
354
|
+
return responses
|
|
355
|
+
|
|
356
|
+
def decide_batch(
|
|
357
|
+
self, requests: Sequence[DecisionRequest]
|
|
358
|
+
) -> list[DecisionResponse]:
|
|
359
|
+
if self._input_mode == "candidate_tasks":
|
|
360
|
+
return self._decide_candidate_tasks(requests)
|
|
361
|
+
texts = [render_probe_input(request, self.metadata) for request in requests]
|
|
362
|
+
scores, input_tokens = self._score_texts(texts)
|
|
363
|
+
probabilities = [
|
|
364
|
+
float(value)
|
|
365
|
+
for value in self._probabilities(
|
|
366
|
+
self._torch.tensor(scores, dtype=self._torch.float32)
|
|
367
|
+
).tolist()
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
responses: list[DecisionResponse] = []
|
|
371
|
+
for request, probability, tokens in zip(
|
|
372
|
+
requests, probabilities, input_tokens, strict=True
|
|
373
|
+
):
|
|
374
|
+
responses.append(
|
|
375
|
+
DecisionResponse(
|
|
376
|
+
model=self.model_name,
|
|
377
|
+
answers={
|
|
378
|
+
name: NoulAnswer(type="noul", noul=probability)
|
|
379
|
+
for name in request.questions
|
|
380
|
+
},
|
|
381
|
+
usage=Usage(input_tokens=tokens),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return responses
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Adapter for the published convaiinnovations/laya checkpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from .protocol import (
|
|
11
|
+
ChoiceAnswer,
|
|
12
|
+
DecisionRequest,
|
|
13
|
+
DecisionResponse,
|
|
14
|
+
NoulAnswer,
|
|
15
|
+
ScoreAnswer,
|
|
16
|
+
Usage,
|
|
17
|
+
)
|
|
18
|
+
from .runtime import DecisionRuntime, RuntimeErrorBase
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LayaBackend(DecisionRuntime):
|
|
22
|
+
"""Run Laya's checkpoint-native RLAgent API behind the JEV wire contract."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, model_id: str, *, config: dict[str, Any] | None = None) -> None:
|
|
25
|
+
try:
|
|
26
|
+
from huggingface_hub import snapshot_download
|
|
27
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
28
|
+
raise RuntimeErrorBase("LayaBackend requires huggingface_hub") from exc
|
|
29
|
+
|
|
30
|
+
self.config = config or {}
|
|
31
|
+
self.model_name = str(self.config.get("model", model_id))
|
|
32
|
+
revision = self.config.get("revision")
|
|
33
|
+
download_kwargs: dict[str, Any] = {}
|
|
34
|
+
if isinstance(revision, str):
|
|
35
|
+
download_kwargs["revision"] = revision
|
|
36
|
+
model_dir = Path(snapshot_download(model_id, **download_kwargs))
|
|
37
|
+
# The published checkpoint ships its inference modules beside the weights.
|
|
38
|
+
source_dir = str(model_dir)
|
|
39
|
+
if source_dir not in sys.path:
|
|
40
|
+
sys.path.insert(0, source_dir)
|
|
41
|
+
try:
|
|
42
|
+
from rl_agent_api import RLAgent # type: ignore[import-not-found]
|
|
43
|
+
except ImportError as exc: # pragma: no cover - model-owned optional code
|
|
44
|
+
raise RuntimeErrorBase(
|
|
45
|
+
"Laya checkpoint does not contain rl_agent_api.py"
|
|
46
|
+
) from exc
|
|
47
|
+
try:
|
|
48
|
+
self._agent = RLAgent(str(model_dir))
|
|
49
|
+
except Exception as exc: # pragma: no cover - model-owned loading errors
|
|
50
|
+
raise RuntimeErrorBase(f"failed to load Laya checkpoint {model_id!r}: {exc}") from exc
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _question_payload(question: Any) -> dict[str, Any]:
|
|
54
|
+
if hasattr(question, "model_dump"):
|
|
55
|
+
payload = question.model_dump(mode="json")
|
|
56
|
+
if isinstance(payload, dict) and all(
|
|
57
|
+
isinstance(key, str) for key in payload
|
|
58
|
+
):
|
|
59
|
+
return cast(dict[str, Any], payload)
|
|
60
|
+
raise RuntimeErrorBase("Laya requires pydantic decision questions")
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _answer(value: Any) -> Any:
|
|
64
|
+
if not isinstance(value, dict) or not isinstance(value.get("type"), str):
|
|
65
|
+
raise RuntimeErrorBase("Laya returned an invalid answer")
|
|
66
|
+
kind = value["type"]
|
|
67
|
+
if kind == "choice":
|
|
68
|
+
probabilities = value.get("probabilities")
|
|
69
|
+
choice = value.get("choice")
|
|
70
|
+
confidence = value.get("confidence")
|
|
71
|
+
if (
|
|
72
|
+
not isinstance(probabilities, dict)
|
|
73
|
+
or not isinstance(choice, str)
|
|
74
|
+
or not isinstance(confidence, (int, float))
|
|
75
|
+
or isinstance(confidence, bool)
|
|
76
|
+
):
|
|
77
|
+
raise RuntimeErrorBase("Laya returned an invalid choice answer")
|
|
78
|
+
return ChoiceAnswer(
|
|
79
|
+
type="choice",
|
|
80
|
+
choice=choice,
|
|
81
|
+
probabilities={str(key): float(prob) for key, prob in probabilities.items()},
|
|
82
|
+
confidence=float(confidence),
|
|
83
|
+
)
|
|
84
|
+
if kind == "score":
|
|
85
|
+
probabilities = value.get("probabilities")
|
|
86
|
+
legend = value.get("legend")
|
|
87
|
+
if not isinstance(probabilities, dict):
|
|
88
|
+
raise RuntimeErrorBase("Laya returned an invalid score answer")
|
|
89
|
+
ordered_legend: list[Any] | None = None
|
|
90
|
+
if isinstance(legend, dict):
|
|
91
|
+
ordered_legend = [legend[key] for key in sorted(legend, key=lambda item: int(item))]
|
|
92
|
+
elif isinstance(legend, list):
|
|
93
|
+
ordered_legend = legend
|
|
94
|
+
return ScoreAnswer(
|
|
95
|
+
type="score",
|
|
96
|
+
score=float(value["score"]),
|
|
97
|
+
probabilities={str(key): float(prob) for key, prob in probabilities.items()},
|
|
98
|
+
confidence=float(value["confidence"]),
|
|
99
|
+
legend=ordered_legend,
|
|
100
|
+
)
|
|
101
|
+
if kind == "noul":
|
|
102
|
+
return NoulAnswer(type="noul", noul=float(value["noul"]))
|
|
103
|
+
raise RuntimeErrorBase(f"Laya returned unsupported answer type: {kind!r}")
|
|
104
|
+
|
|
105
|
+
def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
|
|
106
|
+
responses: list[DecisionResponse] = []
|
|
107
|
+
for request in requests:
|
|
108
|
+
questions = {
|
|
109
|
+
name: self._question_payload(question)
|
|
110
|
+
for name, question in request.questions.items()
|
|
111
|
+
}
|
|
112
|
+
try:
|
|
113
|
+
result = self._agent.system_one(request.state, questions)
|
|
114
|
+
except Exception as exc: # pragma: no cover - model-owned inference errors
|
|
115
|
+
raise RuntimeErrorBase(f"Laya inference failed: {exc}") from exc
|
|
116
|
+
if not isinstance(result, dict) or not isinstance(result.get("answers"), dict):
|
|
117
|
+
raise RuntimeErrorBase("Laya returned no answers")
|
|
118
|
+
answers = {
|
|
119
|
+
name: self._answer(result["answers"][name])
|
|
120
|
+
for name in questions
|
|
121
|
+
if name in result["answers"]
|
|
122
|
+
}
|
|
123
|
+
if len(answers) != len(questions):
|
|
124
|
+
raise RuntimeErrorBase("Laya omitted one or more requested answers")
|
|
125
|
+
usage_value = result.get("usage")
|
|
126
|
+
usage = Usage(
|
|
127
|
+
input_tokens=int(usage_value.get("input_tokens", 0))
|
|
128
|
+
if isinstance(usage_value, dict)
|
|
129
|
+
else 0,
|
|
130
|
+
output_tokens=int(usage_value.get("output_tokens", 0))
|
|
131
|
+
if isinstance(usage_value, dict)
|
|
132
|
+
else 0,
|
|
133
|
+
)
|
|
134
|
+
responses.append(DecisionResponse(model=self.model_name, answers=answers, usage=usage))
|
|
135
|
+
return responses
|