jevify 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.
- jevify/__init__.py +7 -0
- jevify/__main__.py +9 -0
- jevify/adapters/__init__.py +1 -0
- jevify/adapters/embedding/__init__.py +1 -0
- jevify/adapters/embedding/adapter.py +203 -0
- jevify/adapters/embedding/local.py +82 -0
- jevify/adapters/encoder/__init__.py +1 -0
- jevify/adapters/encoder/adapter.py +165 -0
- jevify/adapters/encoder/local.py +77 -0
- jevify/adapters/endpoint/__init__.py +1 -0
- jevify/adapters/endpoint/adapter.py +281 -0
- jevify/adapters/endpoint/client.py +114 -0
- jevify/adapters/endpoint/dialects.py +166 -0
- jevify/adapters/endpoint/probe.py +589 -0
- jevify/adapters/endpoint/readout.py +244 -0
- jevify/adapters/rerank/__init__.py +1 -0
- jevify/adapters/rerank/adapter.py +152 -0
- jevify/api/__init__.py +1 -0
- jevify/api/app.py +228 -0
- jevify/api/schema.py +123 -0
- jevify/api/translate.py +239 -0
- jevify/cli.py +392 -0
- jevify/compose.py +82 -0
- jevify/domain/__init__.py +1 -0
- jevify/domain/distribution.py +103 -0
- jevify/domain/engine.py +152 -0
- jevify/domain/questions.py +121 -0
- jevify/evaluation/__init__.py +1 -0
- jevify/evaluation/calibrate.py +186 -0
- jevify/evaluation/controls.py +78 -0
- jevify/evaluation/metrics.py +91 -0
- jevify/evaluation/scorecard.py +385 -0
- jevify/evaluation/suites.py +166 -0
- jevify/ports/__init__.py +1 -0
- jevify/ports/backend.py +162 -0
- jevify/recipes/__init__.py +21 -0
- jevify/recipes/probe_case.yaml +72 -0
- jevify/recipes/render.py +198 -0
- jevify/recipes/schema.py +204 -0
- jevify/recipes/store.py +81 -0
- jevify-0.1.0.dist-info/METADATA +243 -0
- jevify-0.1.0.dist-info/RECORD +45 -0
- jevify-0.1.0.dist-info/WHEEL +4 -0
- jevify-0.1.0.dist-info/entry_points.txt +2 -0
- jevify-0.1.0.dist-info/licenses/LICENSE +21 -0
jevify/__init__.py
ADDED
jevify/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters implement the ports. Only jevify.compose imports them."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The embedding kind: cosine pertinence over an endpoint or an in-process model."""
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""EmbeddingBackend: the backend port over an embedding model (ADR-0002 D2, embedding kind).
|
|
2
|
+
|
|
3
|
+
The state (with the question's instructions, for an instruction-tuned model) becomes one
|
|
4
|
+
query vector; each option becomes one document vector; the answer is the cosine between
|
|
5
|
+
them. Cosines are independent similarities, so the answer is labeled `similarity`; the
|
|
6
|
+
engine's softmax over cosine times the recipe's scale is a ranking convenience, never a
|
|
7
|
+
probability. Only choice questions are answerable: a noul or score question has no menu of
|
|
8
|
+
texts to compare against.
|
|
9
|
+
|
|
10
|
+
The embedder behind the backend is a narrow inner seam (`Embedder`): one implementation
|
|
11
|
+
posts to an OpenAI-compatible `/v1/embeddings` route, the other runs a model in-process
|
|
12
|
+
behind the `encoder` extra. Both cache option vectors, because a menu repeats across states
|
|
13
|
+
far more often than a state repeats across menus.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import hashlib
|
|
20
|
+
import math
|
|
21
|
+
import time
|
|
22
|
+
from collections import OrderedDict
|
|
23
|
+
from collections.abc import Callable, Sequence
|
|
24
|
+
from typing import Protocol
|
|
25
|
+
|
|
26
|
+
from jevify.adapters.endpoint.client import OpenAICompatibleClient
|
|
27
|
+
from jevify.domain.questions import ChoiceQuestion, Question, State
|
|
28
|
+
from jevify.ports.backend import (
|
|
29
|
+
BackendError,
|
|
30
|
+
Capabilities,
|
|
31
|
+
Dialect,
|
|
32
|
+
RawAnswer,
|
|
33
|
+
Rung,
|
|
34
|
+
StateHandle,
|
|
35
|
+
)
|
|
36
|
+
from jevify.recipes.render import RenderedPrefix
|
|
37
|
+
from jevify.recipes.schema import EmbeddingSpec, Recipe
|
|
38
|
+
|
|
39
|
+
Clock = Callable[[], float]
|
|
40
|
+
Vector = list[float]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
44
|
+
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
|
45
|
+
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
|
46
|
+
return dot / norm if norm else 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fill(template: str, **fields: str) -> str:
|
|
50
|
+
for name, value in fields.items():
|
|
51
|
+
template = template.replace("{" + name + "}", value)
|
|
52
|
+
return template
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Embedder(Protocol):
|
|
56
|
+
"""One batch of texts in, one vector per text out, in order."""
|
|
57
|
+
|
|
58
|
+
async def embed(self, texts: Sequence[str]) -> list[Vector]: ...
|
|
59
|
+
|
|
60
|
+
def describe(self) -> dict[str, str | None]: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EndpointEmbedder:
|
|
64
|
+
def __init__(self, client: OpenAICompatibleClient, recipe: Recipe) -> None:
|
|
65
|
+
assert recipe.embedding is not None and recipe.endpoint is not None
|
|
66
|
+
self.client = client
|
|
67
|
+
self.recipe = recipe
|
|
68
|
+
self.path = recipe.embedding.path
|
|
69
|
+
|
|
70
|
+
async def embed(self, texts: Sequence[str]) -> list[Vector]:
|
|
71
|
+
body = {
|
|
72
|
+
"model": self.recipe.model.name,
|
|
73
|
+
"input": texts[0] if len(texts) == 1 else list(texts),
|
|
74
|
+
}
|
|
75
|
+
response = await self.client.post_v1(self.path, body)
|
|
76
|
+
data = sorted(response.get("data") or [], key=lambda item: item.get("index", 0))
|
|
77
|
+
vectors = [list(map(float, item["embedding"])) for item in data]
|
|
78
|
+
if len(vectors) != len(texts):
|
|
79
|
+
raise BackendError(
|
|
80
|
+
f"embedding endpoint returned {len(vectors)} vectors for {len(texts)} inputs"
|
|
81
|
+
)
|
|
82
|
+
return vectors
|
|
83
|
+
|
|
84
|
+
def describe(self) -> dict[str, str | None]:
|
|
85
|
+
assert self.recipe.endpoint is not None
|
|
86
|
+
dialect = self.recipe.endpoint.dialect
|
|
87
|
+
return {"transport": "endpoint", "dialect": None if dialect == "auto" else dialect}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _Lru:
|
|
91
|
+
def __init__(self, capacity: int) -> None:
|
|
92
|
+
self.capacity = capacity
|
|
93
|
+
self.items: OrderedDict[str, Vector] = OrderedDict()
|
|
94
|
+
|
|
95
|
+
def get(self, key: str) -> Vector | None:
|
|
96
|
+
if key in self.items:
|
|
97
|
+
self.items.move_to_end(key)
|
|
98
|
+
return self.items[key]
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def put(self, key: str, value: Vector) -> None:
|
|
102
|
+
self.items[key] = value
|
|
103
|
+
self.items.move_to_end(key)
|
|
104
|
+
while len(self.items) > self.capacity:
|
|
105
|
+
self.items.popitem(last=False)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EmbeddingBackend:
|
|
109
|
+
def __init__(
|
|
110
|
+
self, embedder: Embedder, recipe: Recipe, *, clock: Clock = time.perf_counter
|
|
111
|
+
) -> None:
|
|
112
|
+
if recipe.embedding is None:
|
|
113
|
+
raise BackendError("an embedding backend needs an embedding section")
|
|
114
|
+
self.embedder = embedder
|
|
115
|
+
self.recipe = recipe
|
|
116
|
+
self.spec: EmbeddingSpec = recipe.embedding
|
|
117
|
+
self.clock = clock
|
|
118
|
+
self.queries = _Lru(64)
|
|
119
|
+
self.options = _Lru(4096)
|
|
120
|
+
|
|
121
|
+
async def probe(self) -> Capabilities:
|
|
122
|
+
try:
|
|
123
|
+
[vector] = await self.embedder.embed(["probe"])
|
|
124
|
+
except BackendError as exc:
|
|
125
|
+
raise BackendError(f"embedding model did not answer the probe: {exc}") from exc
|
|
126
|
+
described = self.embedder.describe()
|
|
127
|
+
dialect = described.get("dialect")
|
|
128
|
+
return Capabilities(
|
|
129
|
+
kind="embedding",
|
|
130
|
+
model=self.recipe.model.name,
|
|
131
|
+
dialect=Dialect(dialect) if dialect else None,
|
|
132
|
+
modalities=frozenset({"text"}),
|
|
133
|
+
rungs=frozenset(),
|
|
134
|
+
token_ids_verified=False,
|
|
135
|
+
probed_at=dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
|
|
136
|
+
notes=(
|
|
137
|
+
f"embedding dimension {len(vector)}",
|
|
138
|
+
f"transport {described.get('transport')}",
|
|
139
|
+
"choice questions only; cosine similarity, labeled similarity",
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def warm(self, state: State) -> StateHandle:
|
|
144
|
+
if state.modalities - {"text"}:
|
|
145
|
+
raise BackendError(
|
|
146
|
+
"the embedding kind compares text only; the state carries image parts"
|
|
147
|
+
)
|
|
148
|
+
prefix = RenderedPrefix(mode="embedding", text=state.text)
|
|
149
|
+
state_id = hashlib.sha256(state.text.encode("utf-8")).hexdigest()
|
|
150
|
+
return StateHandle(state_id=state_id, prefix=prefix)
|
|
151
|
+
|
|
152
|
+
async def evaluate(self, handle: StateHandle, questions: Sequence[Question]) -> list[RawAnswer]:
|
|
153
|
+
return [await self._answer(handle, q) for q in questions]
|
|
154
|
+
|
|
155
|
+
def _option_texts(self, question: ChoiceQuestion) -> list[str]:
|
|
156
|
+
return [
|
|
157
|
+
_fill(
|
|
158
|
+
self.spec.option,
|
|
159
|
+
key=option.key,
|
|
160
|
+
description=(self.spec.description_sep + option.description)
|
|
161
|
+
if option.description
|
|
162
|
+
else "",
|
|
163
|
+
text=option.description or option.key,
|
|
164
|
+
)
|
|
165
|
+
for option in question.options
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
async def _answer(self, handle: StateHandle, question: Question) -> RawAnswer:
|
|
169
|
+
if not isinstance(question, ChoiceQuestion):
|
|
170
|
+
raise BackendError(
|
|
171
|
+
"the embedding kind answers choice questions only; a noul or score question "
|
|
172
|
+
"needs a readout-capable or rerank recipe"
|
|
173
|
+
)
|
|
174
|
+
started = self.clock()
|
|
175
|
+
calls = 0
|
|
176
|
+
query_text = _fill(
|
|
177
|
+
self.spec.query, instructions=question.instructions or "", state=handle.prefix.text
|
|
178
|
+
)
|
|
179
|
+
query = self.queries.get(query_text)
|
|
180
|
+
if query is None:
|
|
181
|
+
[query] = await self.embedder.embed([query_text])
|
|
182
|
+
self.queries.put(query_text, query)
|
|
183
|
+
calls += 1
|
|
184
|
+
texts = self._option_texts(question)
|
|
185
|
+
missing = list(dict.fromkeys(t for t in texts if self.options.get(t) is None))
|
|
186
|
+
if missing:
|
|
187
|
+
for text, vector in zip(missing, await self.embedder.embed(missing), strict=True):
|
|
188
|
+
self.options.put(text, vector)
|
|
189
|
+
calls += 1
|
|
190
|
+
cosines = {
|
|
191
|
+
label: cosine(query, self.options.get(text) or [])
|
|
192
|
+
for label, text in zip(question.labels, texts, strict=True)
|
|
193
|
+
}
|
|
194
|
+
return RawAnswer(
|
|
195
|
+
question_id=question.id,
|
|
196
|
+
logprobs={label: value * self.spec.scale for label, value in cosines.items()},
|
|
197
|
+
semantics="similarity",
|
|
198
|
+
rung=Rung.NATIVE,
|
|
199
|
+
latency_ms=(self.clock() - started) * 1000.0,
|
|
200
|
+
calls=calls,
|
|
201
|
+
composition="per_option",
|
|
202
|
+
raw={"cosine": cosines, "scale": self.spec.scale, "query_cached": calls == 0},
|
|
203
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""LocalEmbedder: an in-process embedding model behind the `encoder` extra.
|
|
2
|
+
|
|
3
|
+
Ported from decision-poc's `Embedding`: left padding, last-token pooling (Qwen3-Embedding's
|
|
4
|
+
reference recipe) or mean pooling, float16 off CPU. torch and transformers are imported
|
|
5
|
+
inside methods so that importing this module never needs the extra; a missing extra fails
|
|
6
|
+
at first use with the install command named.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from jevify.ports.backend import BackendError
|
|
15
|
+
from jevify.recipes.schema import LocalModelSpec
|
|
16
|
+
|
|
17
|
+
_BATCH = 16
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocalEmbedder:
|
|
21
|
+
def __init__(self, spec: LocalModelSpec) -> None:
|
|
22
|
+
self.spec = spec
|
|
23
|
+
self._model: Any = None
|
|
24
|
+
self._tokenizer: Any = None
|
|
25
|
+
self._device: Any = None
|
|
26
|
+
|
|
27
|
+
def _load(self) -> None:
|
|
28
|
+
if self._model is not None:
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
import torch
|
|
32
|
+
from transformers import AutoModel, AutoTokenizer
|
|
33
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
34
|
+
raise BackendError(
|
|
35
|
+
"the local embedding kind needs the encoder extra: "
|
|
36
|
+
"uv tool install 'jevify[encoder]' (or pip install 'jevify[encoder]')"
|
|
37
|
+
) from exc
|
|
38
|
+
name = self.spec.device
|
|
39
|
+
if name == "auto":
|
|
40
|
+
name = (
|
|
41
|
+
"cuda"
|
|
42
|
+
if torch.cuda.is_available()
|
|
43
|
+
else ("mps" if torch.backends.mps.is_available() else "cpu")
|
|
44
|
+
)
|
|
45
|
+
self._device = torch.device(name)
|
|
46
|
+
self._tokenizer = AutoTokenizer.from_pretrained(self.spec.path, padding_side="left")
|
|
47
|
+
self._model = (
|
|
48
|
+
AutoModel.from_pretrained(
|
|
49
|
+
self.spec.path,
|
|
50
|
+
dtype=torch.float32 if self._device.type == "cpu" else torch.float16,
|
|
51
|
+
)
|
|
52
|
+
.to(self._device)
|
|
53
|
+
.eval()
|
|
54
|
+
.requires_grad_(False)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
58
|
+
self._load()
|
|
59
|
+
import torch
|
|
60
|
+
|
|
61
|
+
vectors: list[list[float]] = []
|
|
62
|
+
for start in range(0, len(texts), _BATCH):
|
|
63
|
+
batch = list(texts[start : start + _BATCH])
|
|
64
|
+
encoded = self._tokenizer(batch, padding=True, return_tensors="pt")
|
|
65
|
+
if encoded.input_ids.shape[1] > self.spec.max_tokens:
|
|
66
|
+
raise BackendError(
|
|
67
|
+
f"input exceeds the recipe's local.max_tokens={self.spec.max_tokens} "
|
|
68
|
+
f"({encoded.input_ids.shape[1]} tokens)"
|
|
69
|
+
)
|
|
70
|
+
encoded = encoded.to(self._device)
|
|
71
|
+
with torch.no_grad():
|
|
72
|
+
hidden = self._model(**encoded, use_cache=False).last_hidden_state.float()
|
|
73
|
+
if self.spec.pooling == "last":
|
|
74
|
+
pooled = hidden[:, -1] # left padding puts every last real token at -1
|
|
75
|
+
else:
|
|
76
|
+
mask = encoded.attention_mask.unsqueeze(-1).float()
|
|
77
|
+
pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1.0)
|
|
78
|
+
vectors.extend(pooled.cpu().tolist())
|
|
79
|
+
return vectors
|
|
80
|
+
|
|
81
|
+
def describe(self) -> dict[str, str | None]:
|
|
82
|
+
return {"transport": "local", "dialect": None}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The encoder kind: an in-process sequence-classification head read at each option."""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""EncoderBackend: the backend port over an in-process encoder classifier (ADR-0002 D2,
|
|
2
|
+
encoder kind).
|
|
3
|
+
|
|
4
|
+
The `nli` layout: the state is the premise; each option, level or statement is a
|
|
5
|
+
hypothesis; the head's entailment and contradiction logits at each pair are the readout.
|
|
6
|
+
A choice or score answer carries, per label, the entailment-minus-contradiction logit (the
|
|
7
|
+
engine's softmax across labels ranks them, the same per-option composition the endpoint
|
|
8
|
+
kind uses for a yes/no reranker); a noul answer is the two-way distribution at the one
|
|
9
|
+
pair. The scores are the head's own outputs at the answer position, so the label is
|
|
10
|
+
`readout`. One forward pass scores every pair of a question.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import hashlib
|
|
17
|
+
import math
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
from jevify.domain.questions import (
|
|
23
|
+
ChoiceQuestion,
|
|
24
|
+
NoulQuestion,
|
|
25
|
+
Question,
|
|
26
|
+
ScoreQuestion,
|
|
27
|
+
State,
|
|
28
|
+
)
|
|
29
|
+
from jevify.ports.backend import BackendError, Capabilities, RawAnswer, Rung, StateHandle
|
|
30
|
+
from jevify.recipes.render import RenderedPrefix
|
|
31
|
+
from jevify.recipes.schema import EncoderSpec, Recipe
|
|
32
|
+
|
|
33
|
+
Clock = Callable[[], float]
|
|
34
|
+
Pair = tuple[str, str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fill(template: str, **fields: str) -> str:
|
|
38
|
+
for name, value in fields.items():
|
|
39
|
+
template = template.replace("{" + name + "}", value)
|
|
40
|
+
return template
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Scorer(Protocol):
|
|
44
|
+
"""Premise/hypothesis pairs in, the head's logits by lower-cased label name out."""
|
|
45
|
+
|
|
46
|
+
async def score(self, pairs: Sequence[Pair]) -> list[dict[str, float]]: ...
|
|
47
|
+
|
|
48
|
+
def describe(self) -> dict[str, str | None]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EncoderBackend:
|
|
52
|
+
def __init__(self, scorer: Scorer, recipe: Recipe, *, clock: Clock = time.perf_counter) -> None:
|
|
53
|
+
if recipe.encoder is None:
|
|
54
|
+
raise BackendError("an encoder backend needs an encoder section")
|
|
55
|
+
self.scorer = scorer
|
|
56
|
+
self.recipe = recipe
|
|
57
|
+
self.spec: EncoderSpec = recipe.encoder
|
|
58
|
+
self.clock = clock
|
|
59
|
+
|
|
60
|
+
async def probe(self) -> Capabilities:
|
|
61
|
+
try:
|
|
62
|
+
[logits] = await self.scorer.score([("probe", "probe")])
|
|
63
|
+
except BackendError as exc:
|
|
64
|
+
raise BackendError(f"encoder model did not answer the probe: {exc}") from exc
|
|
65
|
+
wanted = {self.spec.entailment_label.lower(), self.spec.contradiction_label.lower()}
|
|
66
|
+
if not wanted <= set(logits):
|
|
67
|
+
raise BackendError(
|
|
68
|
+
f"the head's labels {sorted(logits)} lack the recipe's "
|
|
69
|
+
f"{sorted(wanted)}; set encoder.entailment_label and encoder.contradiction_label"
|
|
70
|
+
)
|
|
71
|
+
described = self.scorer.describe()
|
|
72
|
+
return Capabilities(
|
|
73
|
+
kind="encoder",
|
|
74
|
+
model=self.recipe.model.name,
|
|
75
|
+
dialect=None,
|
|
76
|
+
modalities=frozenset({"text"}),
|
|
77
|
+
rungs=frozenset({Rung.NATIVE}),
|
|
78
|
+
token_ids_verified=False,
|
|
79
|
+
probed_at=dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
|
|
80
|
+
notes=(
|
|
81
|
+
f"layout {self.spec.layout}; head labels {', '.join(sorted(logits))}",
|
|
82
|
+
f"transport {described.get('transport')}",
|
|
83
|
+
"text only; one forward pass per question",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def warm(self, state: State) -> StateHandle:
|
|
88
|
+
if state.modalities - {"text"}:
|
|
89
|
+
raise BackendError("the encoder kind reads text only; the state carries image parts")
|
|
90
|
+
premise = _fill(self.spec.premise, state=state.text)
|
|
91
|
+
prefix = RenderedPrefix(mode="encoder", text=premise)
|
|
92
|
+
return StateHandle(
|
|
93
|
+
state_id=hashlib.sha256(premise.encode("utf-8")).hexdigest(), prefix=prefix
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def evaluate(self, handle: StateHandle, questions: Sequence[Question]) -> list[RawAnswer]:
|
|
97
|
+
return [await self._answer(handle, q) for q in questions]
|
|
98
|
+
|
|
99
|
+
def _hypotheses(self, question: Question) -> list[str]:
|
|
100
|
+
instructions = question.instructions or ""
|
|
101
|
+
if isinstance(question, ChoiceQuestion):
|
|
102
|
+
return [
|
|
103
|
+
_fill(
|
|
104
|
+
self.spec.hypothesis,
|
|
105
|
+
instructions=instructions,
|
|
106
|
+
key=option.key,
|
|
107
|
+
description=(self.spec.description_sep + option.description)
|
|
108
|
+
if option.description
|
|
109
|
+
else "",
|
|
110
|
+
text=option.description or option.key,
|
|
111
|
+
)
|
|
112
|
+
for option in question.options
|
|
113
|
+
]
|
|
114
|
+
if isinstance(question, ScoreQuestion):
|
|
115
|
+
return [
|
|
116
|
+
_fill(
|
|
117
|
+
self.spec.hypothesis,
|
|
118
|
+
instructions=instructions,
|
|
119
|
+
key=level,
|
|
120
|
+
description="",
|
|
121
|
+
text=level,
|
|
122
|
+
)
|
|
123
|
+
for level in question.levels
|
|
124
|
+
]
|
|
125
|
+
assert isinstance(question, NoulQuestion)
|
|
126
|
+
return [_fill(self.spec.noul_hypothesis, instructions=instructions)]
|
|
127
|
+
|
|
128
|
+
async def _answer(self, handle: StateHandle, question: Question) -> RawAnswer:
|
|
129
|
+
hypotheses = self._hypotheses(question)
|
|
130
|
+
started = self.clock()
|
|
131
|
+
scored = await self.scorer.score([(handle.prefix.text, h) for h in hypotheses])
|
|
132
|
+
latency_ms = (self.clock() - started) * 1000.0
|
|
133
|
+
entail, contra = self.spec.entailment_label.lower(), self.spec.contradiction_label.lower()
|
|
134
|
+
gaps = []
|
|
135
|
+
for logits in scored:
|
|
136
|
+
if entail not in logits or contra not in logits:
|
|
137
|
+
raise BackendError(
|
|
138
|
+
f"the head returned labels {sorted(logits)}, not {entail!r}/{contra!r}"
|
|
139
|
+
)
|
|
140
|
+
gaps.append(logits[entail] - logits[contra])
|
|
141
|
+
if isinstance(question, NoulQuestion):
|
|
142
|
+
[gap] = gaps
|
|
143
|
+
p_true = 1.0 / (1.0 + math.exp(-gap))
|
|
144
|
+
logprobs = {
|
|
145
|
+
"false": math.log(max(1.0 - p_true, 1e-12)),
|
|
146
|
+
"true": math.log(max(p_true, 1e-12)),
|
|
147
|
+
}
|
|
148
|
+
keys: tuple[str, ...] = ("true",)
|
|
149
|
+
else:
|
|
150
|
+
keys = question.labels
|
|
151
|
+
logprobs = dict(zip(keys, gaps, strict=True))
|
|
152
|
+
return RawAnswer(
|
|
153
|
+
question_id=question.id,
|
|
154
|
+
logprobs=logprobs,
|
|
155
|
+
semantics="readout",
|
|
156
|
+
rung=Rung.NATIVE,
|
|
157
|
+
latency_ms=latency_ms,
|
|
158
|
+
calls=1,
|
|
159
|
+
composition="per_option",
|
|
160
|
+
raw={
|
|
161
|
+
"layout": self.spec.layout,
|
|
162
|
+
"hypotheses": dict(zip(keys, hypotheses, strict=True)),
|
|
163
|
+
"logits": dict(zip(keys, scored, strict=True)),
|
|
164
|
+
},
|
|
165
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""NliScorer: a sequence-classification head run in-process behind the `encoder` extra.
|
|
2
|
+
|
|
3
|
+
torch and transformers are imported inside methods so that importing this module never
|
|
4
|
+
needs the extra; a missing extra fails at first use with the install command named.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from jevify.ports.backend import BackendError
|
|
13
|
+
from jevify.recipes.schema import LocalModelSpec
|
|
14
|
+
|
|
15
|
+
_BATCH = 16
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NliScorer:
|
|
19
|
+
def __init__(self, spec: LocalModelSpec) -> None:
|
|
20
|
+
self.spec = spec
|
|
21
|
+
self._model: Any = None
|
|
22
|
+
self._tokenizer: Any = None
|
|
23
|
+
self._device: Any = None
|
|
24
|
+
self._labels: list[str] = []
|
|
25
|
+
|
|
26
|
+
def _load(self) -> None:
|
|
27
|
+
if self._model is not None:
|
|
28
|
+
return
|
|
29
|
+
try:
|
|
30
|
+
import torch
|
|
31
|
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
|
32
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
33
|
+
raise BackendError(
|
|
34
|
+
"the encoder kind needs the encoder extra: "
|
|
35
|
+
"uv tool install 'jevify[encoder]' (or pip install 'jevify[encoder]')"
|
|
36
|
+
) from exc
|
|
37
|
+
name = self.spec.device
|
|
38
|
+
if name == "auto":
|
|
39
|
+
name = (
|
|
40
|
+
"cuda"
|
|
41
|
+
if torch.cuda.is_available()
|
|
42
|
+
else ("mps" if torch.backends.mps.is_available() else "cpu")
|
|
43
|
+
)
|
|
44
|
+
self._device = torch.device(name)
|
|
45
|
+
self._tokenizer = AutoTokenizer.from_pretrained(self.spec.path)
|
|
46
|
+
self._model = (
|
|
47
|
+
AutoModelForSequenceClassification.from_pretrained(self.spec.path)
|
|
48
|
+
.to(self._device)
|
|
49
|
+
.eval()
|
|
50
|
+
.requires_grad_(False)
|
|
51
|
+
)
|
|
52
|
+
id2label = self._model.config.id2label
|
|
53
|
+
self._labels = [str(id2label[i]).lower() for i in range(len(id2label))]
|
|
54
|
+
|
|
55
|
+
async def score(self, pairs: Sequence[tuple[str, str]]) -> list[dict[str, float]]:
|
|
56
|
+
self._load()
|
|
57
|
+
import torch
|
|
58
|
+
|
|
59
|
+
out: list[dict[str, float]] = []
|
|
60
|
+
for start in range(0, len(pairs), _BATCH):
|
|
61
|
+
batch = list(pairs[start : start + _BATCH])
|
|
62
|
+
encoded = self._tokenizer(
|
|
63
|
+
[p for p, _ in batch], [h for _, h in batch], padding=True, return_tensors="pt"
|
|
64
|
+
)
|
|
65
|
+
if encoded.input_ids.shape[1] > self.spec.max_tokens:
|
|
66
|
+
raise BackendError(
|
|
67
|
+
f"input exceeds the recipe's local.max_tokens={self.spec.max_tokens} "
|
|
68
|
+
f"({encoded.input_ids.shape[1]} tokens)"
|
|
69
|
+
)
|
|
70
|
+
with torch.no_grad():
|
|
71
|
+
logits = self._model(**encoded.to(self._device)).logits.float().cpu()
|
|
72
|
+
for row in logits.tolist():
|
|
73
|
+
out.append(dict(zip(self._labels, row, strict=True)))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def describe(self) -> dict[str, str | None]:
|
|
77
|
+
return {"transport": "local", "dialect": None}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The endpoint adapter: any OpenAI-compatible server, with dialect extras detected by the probe."""
|