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.
Files changed (45) hide show
  1. jevify/__init__.py +7 -0
  2. jevify/__main__.py +9 -0
  3. jevify/adapters/__init__.py +1 -0
  4. jevify/adapters/embedding/__init__.py +1 -0
  5. jevify/adapters/embedding/adapter.py +203 -0
  6. jevify/adapters/embedding/local.py +82 -0
  7. jevify/adapters/encoder/__init__.py +1 -0
  8. jevify/adapters/encoder/adapter.py +165 -0
  9. jevify/adapters/encoder/local.py +77 -0
  10. jevify/adapters/endpoint/__init__.py +1 -0
  11. jevify/adapters/endpoint/adapter.py +281 -0
  12. jevify/adapters/endpoint/client.py +114 -0
  13. jevify/adapters/endpoint/dialects.py +166 -0
  14. jevify/adapters/endpoint/probe.py +589 -0
  15. jevify/adapters/endpoint/readout.py +244 -0
  16. jevify/adapters/rerank/__init__.py +1 -0
  17. jevify/adapters/rerank/adapter.py +152 -0
  18. jevify/api/__init__.py +1 -0
  19. jevify/api/app.py +228 -0
  20. jevify/api/schema.py +123 -0
  21. jevify/api/translate.py +239 -0
  22. jevify/cli.py +392 -0
  23. jevify/compose.py +82 -0
  24. jevify/domain/__init__.py +1 -0
  25. jevify/domain/distribution.py +103 -0
  26. jevify/domain/engine.py +152 -0
  27. jevify/domain/questions.py +121 -0
  28. jevify/evaluation/__init__.py +1 -0
  29. jevify/evaluation/calibrate.py +186 -0
  30. jevify/evaluation/controls.py +78 -0
  31. jevify/evaluation/metrics.py +91 -0
  32. jevify/evaluation/scorecard.py +385 -0
  33. jevify/evaluation/suites.py +166 -0
  34. jevify/ports/__init__.py +1 -0
  35. jevify/ports/backend.py +162 -0
  36. jevify/recipes/__init__.py +21 -0
  37. jevify/recipes/probe_case.yaml +72 -0
  38. jevify/recipes/render.py +198 -0
  39. jevify/recipes/schema.py +204 -0
  40. jevify/recipes/store.py +81 -0
  41. jevify-0.1.0.dist-info/METADATA +243 -0
  42. jevify-0.1.0.dist-info/RECORD +45 -0
  43. jevify-0.1.0.dist-info/WHEEL +4 -0
  44. jevify-0.1.0.dist-info/entry_points.txt +2 -0
  45. jevify-0.1.0.dist-info/licenses/LICENSE +21 -0
jevify/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """jevify: make a pretrained model answer typed questions in one pass, and measure it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
jevify/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """`python -m jevify` runs the same entry point as the `jevify` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from jevify.cli import main
8
+
9
+ sys.exit(main())
@@ -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."""