sys1bench 0.3.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.
- sys1bench/__init__.py +3 -0
- sys1bench/adapters/__init__.py +19 -0
- sys1bench/adapters/base.py +159 -0
- sys1bench/adapters/embed_knn.py +61 -0
- sys1bench/adapters/encoder_finetuned.py +114 -0
- sys1bench/adapters/generic_http.py +161 -0
- sys1bench/adapters/hybrid_router.py +49 -0
- sys1bench/adapters/jev_openrouter.py +172 -0
- sys1bench/adapters/jev_typesafe.py +165 -0
- sys1bench/adapters/laya_local.py +192 -0
- sys1bench/adapters/llm_constrained.py +104 -0
- sys1bench/adapters/majority_prior.py +53 -0
- sys1bench/adapters/mock.py +77 -0
- sys1bench/adapters/nli_zeroshot.py +50 -0
- sys1bench/adapters/regex_keyword.py +70 -0
- sys1bench/analysis/__init__.py +4 -0
- sys1bench/analysis/arms.py +46 -0
- sys1bench/analysis/decision_value.py +43 -0
- sys1bench/analysis/decomposition.py +47 -0
- sys1bench/analysis/meta_eval.py +67 -0
- sys1bench/analysis/stats.py +76 -0
- sys1bench/cli.py +434 -0
- sys1bench/data/__init__.py +37 -0
- sys1bench/data/canary/canary.jsonl +200 -0
- sys1bench/data/framings/guardrail_intent.yaml +21 -0
- sys1bench/data/framings/log_triage.yaml +20 -0
- sys1bench/data/framings/phishing_email.yaml +25 -0
- sys1bench/data/framings/policy_compliance.yaml +18 -0
- sys1bench/data/framings/support_tickets.yaml +41 -0
- sys1bench/framing/__init__.py +10 -0
- sys1bench/framing/expand.py +214 -0
- sys1bench/framing/perturb.py +67 -0
- sys1bench/generators/__init__.py +10 -0
- sys1bench/generators/base.py +105 -0
- sys1bench/generators/guardrail_intent.py +95 -0
- sys1bench/generators/log_triage.py +100 -0
- sys1bench/generators/multilingual_tickets.py +84 -0
- sys1bench/generators/phishing_email.py +127 -0
- sys1bench/generators/policy_compliance.py +90 -0
- sys1bench/generators/rag_relevance.py +122 -0
- sys1bench/generators/support_tickets.py +212 -0
- sys1bench/metrics/__init__.py +9 -0
- sys1bench/metrics/calibration.py +164 -0
- sys1bench/metrics/consistency.py +71 -0
- sys1bench/metrics/decision_value.py +43 -0
- sys1bench/metrics/efficiency.py +35 -0
- sys1bench/metrics/ordinal.py +80 -0
- sys1bench/metrics/robustness.py +46 -0
- sys1bench/metrics/selective.py +67 -0
- sys1bench/report/__init__.py +1 -0
- sys1bench/report/dashboard.py +177 -0
- sys1bench/report/latex.py +40 -0
- sys1bench/report/plots.py +143 -0
- sys1bench/report/results_doc.py +237 -0
- sys1bench/report/scorecard.py +170 -0
- sys1bench/runners/__init__.py +2 -0
- sys1bench/runners/benchmark_runner.py +149 -0
- sys1bench/runners/cache.py +53 -0
- sys1bench/runners/canary.py +43 -0
- sys1bench/runners/hybrid_sweep.py +37 -0
- sys1bench/runners/noul_consistency.py +121 -0
- sys1bench/runners/ordinal_probes.py +90 -0
- sys1bench/runners/robustness.py +112 -0
- sys1bench/runners/suite.py +183 -0
- sys1bench/runners/sweeps.py +190 -0
- sys1bench/schemas.py +274 -0
- sys1bench-0.3.0.dist-info/METADATA +195 -0
- sys1bench-0.3.0.dist-info/RECORD +72 -0
- sys1bench-0.3.0.dist-info/WHEEL +5 -0
- sys1bench-0.3.0.dist-info/entry_points.txt +2 -0
- sys1bench-0.3.0.dist-info/licenses/LICENSE +202 -0
- sys1bench-0.3.0.dist-info/top_level.txt +1 -0
sys1bench/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Model adapters. Register new models with `@register("id")` or via the
|
|
2
|
+
`sys1bench.adapters` entry-point group so future models need no changes here."""
|
|
3
|
+
|
|
4
|
+
from . import ( # noqa: F401
|
|
5
|
+
generic_http,
|
|
6
|
+
hybrid_router,
|
|
7
|
+
jev_openrouter,
|
|
8
|
+
jev_typesafe,
|
|
9
|
+
laya_local,
|
|
10
|
+
majority_prior,
|
|
11
|
+
mock,
|
|
12
|
+
regex_keyword,
|
|
13
|
+
)
|
|
14
|
+
from .base import BaseAdapter, get_adapter, list_adapters, register # noqa: F401
|
|
15
|
+
|
|
16
|
+
try: # optional heavy deps
|
|
17
|
+
from . import embed_knn, encoder_finetuned, llm_constrained, nli_zeroshot # noqa: F401
|
|
18
|
+
except Exception: # pragma: no cover
|
|
19
|
+
pass
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata as md
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..schemas import Answer, DecisionRequest, DecisionResponse, ModelCapabilities, Question
|
|
12
|
+
|
|
13
|
+
_REGISTRY: dict[str, type[BaseAdapter]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FatalAdapterError(RuntimeError):
|
|
17
|
+
"""Raised when an adapter cannot produce valid measurements at all (e.g. a local model refused to load on the
|
|
18
|
+
requested device). The runner does not swallow it: the run aborts so an orchestrator can retry or fail loudly."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(adapter_id: str) -> Callable[[type[BaseAdapter]], type[BaseAdapter]]:
|
|
22
|
+
def deco(cls: type[BaseAdapter]) -> type[BaseAdapter]:
|
|
23
|
+
cls.adapter_id = adapter_id
|
|
24
|
+
_REGISTRY[adapter_id] = cls
|
|
25
|
+
return cls
|
|
26
|
+
|
|
27
|
+
return deco
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_entry_points() -> None:
|
|
31
|
+
try:
|
|
32
|
+
eps = md.entry_points(group="sys1bench.adapters")
|
|
33
|
+
except Exception: # pragma: no cover
|
|
34
|
+
return
|
|
35
|
+
for ep in eps:
|
|
36
|
+
if ep.name not in _REGISTRY:
|
|
37
|
+
try:
|
|
38
|
+
cls = ep.load()
|
|
39
|
+
cls.adapter_id = ep.name
|
|
40
|
+
_REGISTRY[ep.name] = cls
|
|
41
|
+
except Exception: # pragma: no cover
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_adapters() -> list[str]:
|
|
46
|
+
_load_entry_points()
|
|
47
|
+
return sorted(_REGISTRY)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_adapter(adapter_id: str, **config: Any) -> BaseAdapter:
|
|
51
|
+
_load_entry_points()
|
|
52
|
+
if adapter_id not in _REGISTRY:
|
|
53
|
+
raise KeyError(f"unknown adapter {adapter_id!r}; known: {list_adapters()}")
|
|
54
|
+
return _REGISTRY[adapter_id](**config)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_quantisation(probs: list[float]) -> float | None:
|
|
58
|
+
"""Smallest decimal step that reproduces every probability (0.01, 0.001, ...), or None if finer than 1e-6."""
|
|
59
|
+
arr = np.asarray(probs, dtype=float)
|
|
60
|
+
if not np.all(np.isfinite(arr)):
|
|
61
|
+
return None
|
|
62
|
+
for step in (0.1, 0.05, 0.01, 0.005, 0.001, 1e-4, 1e-5, 1e-6):
|
|
63
|
+
if np.allclose(np.round(arr / step) * step, arr, atol=step * 1e-3):
|
|
64
|
+
return step
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
SUM_EXACT_TOL = 1e-4 # sums within this are taken as-is
|
|
69
|
+
SUM_SLACK_TOL = 0.02 # sums within this are rescaled and flagged `renormalised` (vendors quantise to 0.01)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def finalize_answer(q: Question, probs: list[float], *, confidence: float | None = None,
|
|
73
|
+
abstained: bool = False, truncated: bool = False, tol: float = SUM_SLACK_TOL) -> Answer:
|
|
74
|
+
"""Validate a probability vector against the contract. Vectors whose sum is off by more than `tol` are recorded as
|
|
75
|
+
schema failures. Vectors off by less (quantisation slack, e.g. 0.99 from 0.01-rounded probabilities) are rescaled
|
|
76
|
+
and flagged `renormalised=True` with the raw sum kept, so the rate is reportable and nothing is hidden."""
|
|
77
|
+
keys = q.option_keys
|
|
78
|
+
if len(probs) != len(keys):
|
|
79
|
+
return Answer.failed(q, f"probs length {len(probs)} != options {len(keys)}")
|
|
80
|
+
arr = np.asarray(probs, dtype=float)
|
|
81
|
+
if not np.all(np.isfinite(arr)):
|
|
82
|
+
return Answer.failed(q, "non-finite probability")
|
|
83
|
+
if np.any(arr < -tol) or np.any(arr > 1 + tol):
|
|
84
|
+
return Answer.failed(q, "probability outside [0,1]")
|
|
85
|
+
s = float(arr.sum())
|
|
86
|
+
if abs(s - 1.0) > tol or s <= 0:
|
|
87
|
+
return Answer.failed(q, f"probabilities sum to {s:.6f}")
|
|
88
|
+
step = detect_quantisation(list(arr))
|
|
89
|
+
renorm = abs(s - 1.0) > SUM_EXACT_TOL
|
|
90
|
+
if renorm:
|
|
91
|
+
arr = arr / s
|
|
92
|
+
arr = np.clip(arr, 0.0, 1.0)
|
|
93
|
+
return Answer(
|
|
94
|
+
type=q.type,
|
|
95
|
+
option_keys=keys,
|
|
96
|
+
probs=[float(x) for x in arr],
|
|
97
|
+
argmax=keys[int(arr.argmax())],
|
|
98
|
+
confidence=confidence,
|
|
99
|
+
abstained=abstained,
|
|
100
|
+
truncated=truncated,
|
|
101
|
+
renormalised=renorm,
|
|
102
|
+
raw_prob_sum=s,
|
|
103
|
+
quantisation_step=step,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BaseAdapter(ABC):
|
|
108
|
+
adapter_id: str = "base"
|
|
109
|
+
|
|
110
|
+
def __init__(self, model_id: str = "", **tunables: Any) -> None:
|
|
111
|
+
self.model_id = model_id
|
|
112
|
+
self.tunables: dict[str, Any] = tunables
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def capabilities(self) -> ModelCapabilities: ...
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse: ...
|
|
120
|
+
|
|
121
|
+
def decide_batch(self, requests: list[DecisionRequest]) -> list[DecisionResponse]:
|
|
122
|
+
return [self.decide(r) for r in requests]
|
|
123
|
+
|
|
124
|
+
def reparse(self, request: DecisionRequest, cached: DecisionResponse) -> DecisionResponse | None:
|
|
125
|
+
"""Rebuild answers from `cached.raw` with the current parser (no API call). Adapters that store the vendor
|
|
126
|
+
payload verbatim override `parse_raw_answer`; return None if nothing can be rebuilt."""
|
|
127
|
+
raw_answers = (cached.raw or {}).get("answers") if isinstance(cached.raw, dict) else None
|
|
128
|
+
if not isinstance(raw_answers, dict):
|
|
129
|
+
return None
|
|
130
|
+
answers = {}
|
|
131
|
+
for k, q in request.questions.items():
|
|
132
|
+
payload = raw_answers.get(k)
|
|
133
|
+
answers[k] = self.parse_raw_answer(q, payload) if isinstance(payload, dict) else Answer.failed(q, "missing answer")
|
|
134
|
+
return cached.model_copy(update={"answers": answers})
|
|
135
|
+
|
|
136
|
+
def parse_raw_answer(self, q: Question, payload: dict) -> Answer: # pragma: no cover - overridden
|
|
137
|
+
raise NotImplementedError
|
|
138
|
+
|
|
139
|
+
def warmup(self) -> None: # pragma: no cover
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def check_request(self, request: DecisionRequest) -> list[str]:
|
|
143
|
+
"""Capability violations for this request (reported, not fatal)."""
|
|
144
|
+
caps = self.capabilities
|
|
145
|
+
issues: list[str] = []
|
|
146
|
+
if caps.max_questions_per_request and len(request.questions) > caps.max_questions_per_request:
|
|
147
|
+
issues.append("too_many_questions")
|
|
148
|
+
for k, q in request.questions.items():
|
|
149
|
+
if q.type not in caps.primitives:
|
|
150
|
+
issues.append(f"{k}:unsupported_primitive")
|
|
151
|
+
if q.type == "choice" and caps.max_options and q.cardinality > caps.max_options:
|
|
152
|
+
issues.append(f"{k}:too_many_options")
|
|
153
|
+
if q.type == "score" and caps.max_score_levels and q.cardinality > caps.max_score_levels:
|
|
154
|
+
issues.append(f"{k}:too_many_levels")
|
|
155
|
+
return issues
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _now_ms() -> float:
|
|
159
|
+
return time.perf_counter() * 1000.0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Embedding baseline: embed the state and each option description, softmax over cosine similarity
|
|
2
|
+
with a temperature fitted on a calibration split. This is the honest apples-to-apples for
|
|
3
|
+
"zero-shot from label descriptions"; a System One model should beat it by a clear margin."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
|
|
12
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register("embed_knn")
|
|
16
|
+
class EmbedKNNAdapter(BaseAdapter):
|
|
17
|
+
def __init__(self, model_id: str = "BAAI/bge-m3", temperature: float = 0.05, device: str | None = None, **tunables) -> None:
|
|
18
|
+
super().__init__(model_id, **tunables)
|
|
19
|
+
self.temperature, self.device = temperature, device
|
|
20
|
+
self._m = None
|
|
21
|
+
self._cache: dict[str, np.ndarray] = {}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def capabilities(self) -> ModelCapabilities:
|
|
25
|
+
return ModelCapabilities(name=f"embed_knn:{self.model_id}", deployment="local", supports_batching=True,
|
|
26
|
+
tunables={"temperature": self.temperature})
|
|
27
|
+
|
|
28
|
+
def _model(self):
|
|
29
|
+
if self._m is None:
|
|
30
|
+
from sentence_transformers import SentenceTransformer # type: ignore
|
|
31
|
+
|
|
32
|
+
self._m = SentenceTransformer(self.model_id, device=self.device)
|
|
33
|
+
return self._m
|
|
34
|
+
|
|
35
|
+
def _embed(self, texts: list[str]) -> np.ndarray:
|
|
36
|
+
todo = [t for t in texts if t not in self._cache]
|
|
37
|
+
if todo:
|
|
38
|
+
vecs = self._model().encode(todo, normalize_embeddings=True, convert_to_numpy=True)
|
|
39
|
+
for t, v in zip(todo, vecs):
|
|
40
|
+
self._cache[t] = v
|
|
41
|
+
return np.stack([self._cache[t] for t in texts])
|
|
42
|
+
|
|
43
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
44
|
+
t0 = time.perf_counter()
|
|
45
|
+
s = self._embed([request.state_text()])[0]
|
|
46
|
+
answers = {}
|
|
47
|
+
for k, q in request.questions.items():
|
|
48
|
+
if q.type == "noul":
|
|
49
|
+
texts = [f"{q.instructions} Yes.", f"{q.instructions} No."]
|
|
50
|
+
elif q.type == "choice":
|
|
51
|
+
texts = [f"{c.key}: {c.description}" if c.description else c.key for c in q.criteria] # type: ignore[union-attr]
|
|
52
|
+
else:
|
|
53
|
+
texts = [f"level {c.level}: {c.description}" for c in q.criteria] # type: ignore[union-attr]
|
|
54
|
+
sims = self._embed(texts) @ s
|
|
55
|
+
z = sims / self.temperature
|
|
56
|
+
p = np.exp(z - z.max())
|
|
57
|
+
p /= p.sum()
|
|
58
|
+
answers[k] = finalize_answer(q, [float(x) for x in p], confidence=float(p.max()))
|
|
59
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
60
|
+
return DecisionResponse(answers=answers, latency=LatencyRecord(client_ms=ms, compute_ms=ms, timestamp=time.time()),
|
|
61
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Fine-tuned encoder baseline: a small classifier (DistilBERT by default) trained per question on a *disjoint* seed of
|
|
2
|
+
the same generator, then evaluated on the benchmark manifest. This is the "strongest cheap baseline" from jevbench:
|
|
3
|
+
it has seen thousands of labelled examples where the System One models see only descriptions, and the report says so.
|
|
4
|
+
|
|
5
|
+
adapter: encoder_finetuned
|
|
6
|
+
model_id: distilbert-base-uncased
|
|
7
|
+
train_generator: support_tickets
|
|
8
|
+
train_n: 4000
|
|
9
|
+
train_seed: 7 # never the benchmark seed
|
|
10
|
+
epochs: 2
|
|
11
|
+
Trains one head per question key at first use (cached under ~/.cache/sys1bench/encoders/<hash>).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from ..schemas import Answer, DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
|
|
24
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@register("encoder_finetuned")
|
|
28
|
+
class EncoderFinetunedAdapter(BaseAdapter):
|
|
29
|
+
def __init__(self, model_id: str = "distilbert-base-uncased", train_generator: str = "support_tickets", train_n: int = 4000,
|
|
30
|
+
train_seed: int = 7, epochs: int = 2, batch_size: int = 32, lr: float = 5e-5, max_length: int = 256,
|
|
31
|
+
device: str | None = None, cache_dir: str | None = None, **tunables) -> None:
|
|
32
|
+
super().__init__(model_id, **tunables)
|
|
33
|
+
self.train_generator, self.train_n, self.train_seed, self.epochs = train_generator, train_n, train_seed, epochs
|
|
34
|
+
self.batch_size, self.lr, self.max_length, self.device = batch_size, lr, max_length, device
|
|
35
|
+
self.cache_dir = Path(cache_dir or Path.home() / ".cache" / "sys1bench" / "encoders")
|
|
36
|
+
self.tunables.update({"train_generator": train_generator, "train_n": train_n, "train_seed": train_seed, "epochs": epochs})
|
|
37
|
+
self._models: dict[str, tuple] = {}
|
|
38
|
+
self._tok = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def capabilities(self) -> ModelCapabilities:
|
|
42
|
+
return ModelCapabilities(name=f"encoder_ft:{self.model_id}:{self.train_generator}", deployment="local", supports_batching=True,
|
|
43
|
+
tunables={"train_n": self.train_n, "train_seed": self.train_seed, "note": "trained on labelled examples; not zero-shot"})
|
|
44
|
+
|
|
45
|
+
def _key(self, qkey: str, labels: list[str]) -> str:
|
|
46
|
+
return hashlib.sha256(json.dumps([self.model_id, self.train_generator, self.train_n, self.train_seed, self.epochs, qkey, labels]).encode()).hexdigest()[:16]
|
|
47
|
+
|
|
48
|
+
def _ensure(self, qkey: str, labels: list[str], qtype: str):
|
|
49
|
+
if qkey in self._models:
|
|
50
|
+
return self._models[qkey]
|
|
51
|
+
import torch # type: ignore
|
|
52
|
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer # type: ignore
|
|
53
|
+
|
|
54
|
+
from ..generators import get_generator
|
|
55
|
+
|
|
56
|
+
dev = self.device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
57
|
+
if self._tok is None:
|
|
58
|
+
self._tok = AutoTokenizer.from_pretrained(self.model_id)
|
|
59
|
+
path = self.cache_dir / self._key(qkey, labels)
|
|
60
|
+
if path.exists():
|
|
61
|
+
model = AutoModelForSequenceClassification.from_pretrained(path).to(dev).eval()
|
|
62
|
+
else:
|
|
63
|
+
items = get_generator(self.train_generator, n=self.train_n, seed=self.train_seed).generate()
|
|
64
|
+
texts, ys = [], []
|
|
65
|
+
for it in items:
|
|
66
|
+
q = it.questions.get(qkey)
|
|
67
|
+
if q is None or q.ground_truth is None:
|
|
68
|
+
continue
|
|
69
|
+
gt = str(q.ground_truth).lower() if q.type == "noul" else str(q.ground_truth)
|
|
70
|
+
if gt in labels:
|
|
71
|
+
texts.append(it.state_text())
|
|
72
|
+
ys.append(labels.index(gt))
|
|
73
|
+
model = AutoModelForSequenceClassification.from_pretrained(self.model_id, num_labels=len(labels)).to(dev)
|
|
74
|
+
opt = torch.optim.AdamW(model.parameters(), lr=self.lr)
|
|
75
|
+
model.train()
|
|
76
|
+
idx = np.arange(len(texts))
|
|
77
|
+
rng = np.random.default_rng(self.train_seed)
|
|
78
|
+
for _ in range(self.epochs):
|
|
79
|
+
rng.shuffle(idx)
|
|
80
|
+
for b in range(0, len(idx), self.batch_size):
|
|
81
|
+
bi = idx[b:b + self.batch_size]
|
|
82
|
+
enc = self._tok([texts[i] for i in bi], truncation=True, max_length=self.max_length, padding=True, return_tensors="pt").to(dev)
|
|
83
|
+
out = model(**enc, labels=torch.tensor([ys[i] for i in bi], device=dev))
|
|
84
|
+
out.loss.backward()
|
|
85
|
+
opt.step()
|
|
86
|
+
opt.zero_grad()
|
|
87
|
+
model.eval()
|
|
88
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
model.save_pretrained(path)
|
|
90
|
+
self._models[qkey] = (model, dev)
|
|
91
|
+
return self._models[qkey]
|
|
92
|
+
|
|
93
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
94
|
+
import torch # type: ignore
|
|
95
|
+
|
|
96
|
+
t0 = self._now_ms()
|
|
97
|
+
answers = {}
|
|
98
|
+
hw = None
|
|
99
|
+
for k, q in request.questions.items():
|
|
100
|
+
labels = q.option_keys
|
|
101
|
+
try:
|
|
102
|
+
model, dev = self._ensure(k, labels, q.type)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
answers[k] = Answer.failed(q, f"train_failed:{type(e).__name__}")
|
|
105
|
+
continue
|
|
106
|
+
enc = self._tok([request.state_text()], truncation=True, max_length=self.max_length, padding=True, return_tensors="pt").to(dev)
|
|
107
|
+
with torch.no_grad():
|
|
108
|
+
p = torch.softmax(model(**enc).logits[0].float(), -1).cpu().numpy()
|
|
109
|
+
answers[k] = finalize_answer(q, [float(x) for x in p], confidence=float(p.max()))
|
|
110
|
+
hw = hw or (torch.cuda.get_device_name(0) if "cuda" in str(dev) else "cpu")
|
|
111
|
+
ms = self._now_ms() - t0
|
|
112
|
+
return DecisionResponse(answers=answers, latency=LatencyRecord(client_ms=ms, compute_ms=ms, timestamp=time.time()),
|
|
113
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, model_id_returned=f"encoder_ft:{self.model_id}",
|
|
114
|
+
version_hash=f"{self.train_generator}@n{self.train_n}s{self.train_seed}e{self.epochs}", hardware=hw, cost_usd=0.0))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Config-driven adapter for future hosted System One models.
|
|
2
|
+
|
|
3
|
+
A new vendor is added with a YAML file, not code:
|
|
4
|
+
|
|
5
|
+
adapter: generic_http
|
|
6
|
+
model_id: vendor/model-1.0
|
|
7
|
+
url: https://api.vendor.com/v1/decide
|
|
8
|
+
auth_env: VENDOR_API_KEY
|
|
9
|
+
type_map: {choice: choice, score: score, noul: boolean}
|
|
10
|
+
body_template: {"model": "{model_id}", "input": "{state}", "questions": "{questions}"}
|
|
11
|
+
answers_path: ["result", "answers"]
|
|
12
|
+
fields: {probs: ["probabilities", "distribution"], p_true: ["probability"], confidence: ["confidence"],
|
|
13
|
+
model_returned: ["model"], generation_id: ["id"], input_tokens: ["usage", "input_tokens"]}
|
|
14
|
+
price_per_m_input: 0.05
|
|
15
|
+
|
|
16
|
+
Anything the template cannot express is a real adapter; this covers the common JSON shape.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import random
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from ..schemas import (
|
|
30
|
+
Answer,
|
|
31
|
+
DecisionRequest,
|
|
32
|
+
DecisionResponse,
|
|
33
|
+
LatencyRecord,
|
|
34
|
+
ModelCapabilities,
|
|
35
|
+
ProviderRecord,
|
|
36
|
+
Question,
|
|
37
|
+
)
|
|
38
|
+
from .base import BaseAdapter, finalize_answer, register
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dig(d: Any, path: list[str] | str | None, default=None):
|
|
42
|
+
if path is None:
|
|
43
|
+
return default
|
|
44
|
+
if isinstance(path, str):
|
|
45
|
+
path = [path]
|
|
46
|
+
cur = d
|
|
47
|
+
for p in path:
|
|
48
|
+
if not isinstance(cur, dict) or p not in cur:
|
|
49
|
+
return default
|
|
50
|
+
cur = cur[p]
|
|
51
|
+
return cur
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _first_path(d: Any, paths: list[Any] | None, default=None):
|
|
55
|
+
for p in paths or []:
|
|
56
|
+
v = _dig(d, p)
|
|
57
|
+
if v is not None:
|
|
58
|
+
return v
|
|
59
|
+
return default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@register("generic_http")
|
|
63
|
+
class GenericHTTPAdapter(BaseAdapter):
|
|
64
|
+
def __init__(self, model_id: str, url: str, auth_env: str | None = None, headers: dict | None = None,
|
|
65
|
+
type_map: dict | None = None, body_template: dict | None = None, answers_path: list | None = None,
|
|
66
|
+
fields: dict | None = None, price_per_m_input: float | None = None, deployment: str = "hosted",
|
|
67
|
+
max_options: int | None = None, max_state_tokens: int | None = None, timeout_s: float = 30.0,
|
|
68
|
+
max_retries: int = 5, **tunables) -> None:
|
|
69
|
+
super().__init__(model_id, **tunables)
|
|
70
|
+
self.url, self.timeout_s, self.max_retries = url, timeout_s, max_retries
|
|
71
|
+
self.headers = dict(headers or {})
|
|
72
|
+
if auth_env and os.environ.get(auth_env):
|
|
73
|
+
self.headers.setdefault("Authorization", f"Bearer {os.environ[auth_env]}")
|
|
74
|
+
self.type_map = type_map or {"choice": "choice", "score": "score", "noul": "boolean"}
|
|
75
|
+
self.body_template = body_template or {"model": "{model_id}", "state": "{state}", "questions": "{questions}"}
|
|
76
|
+
self.answers_path = answers_path or ["answers"]
|
|
77
|
+
self.fields = fields or {}
|
|
78
|
+
self.price = price_per_m_input
|
|
79
|
+
self._caps = ModelCapabilities(name=model_id, deployment=deployment, max_options=max_options, # type: ignore[arg-type]
|
|
80
|
+
max_state_tokens=max_state_tokens, supports_batching=True)
|
|
81
|
+
self._client = httpx.Client(timeout=timeout_s)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def capabilities(self) -> ModelCapabilities:
|
|
85
|
+
return self._caps
|
|
86
|
+
|
|
87
|
+
def _vendor_q(self, q: Question) -> dict[str, Any]:
|
|
88
|
+
out: dict[str, Any] = {"type": self.type_map[q.type], "instructions": q.instructions}
|
|
89
|
+
if q.type == "choice":
|
|
90
|
+
out["criteria"] = [{"key": c.key, "description": c.description} for c in q.criteria] # type: ignore[union-attr]
|
|
91
|
+
elif q.type == "score":
|
|
92
|
+
out["criteria"] = [{"level": c.level, "description": c.description} for c in q.criteria] # type: ignore[union-attr]
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def _render(self, request: DecisionRequest) -> dict[str, Any]:
|
|
96
|
+
subs = {"{model_id}": self.model_id, "{state}": request.state,
|
|
97
|
+
"{questions}": {k: self._vendor_q(q) for k, q in request.questions.items()}}
|
|
98
|
+
|
|
99
|
+
def walk(x):
|
|
100
|
+
if isinstance(x, str) and x in subs:
|
|
101
|
+
return subs[x]
|
|
102
|
+
if isinstance(x, str):
|
|
103
|
+
return x.replace("{model_id}", self.model_id)
|
|
104
|
+
if isinstance(x, dict):
|
|
105
|
+
return {k: walk(v) for k, v in x.items()}
|
|
106
|
+
if isinstance(x, list):
|
|
107
|
+
return [walk(v) for v in x]
|
|
108
|
+
return x
|
|
109
|
+
|
|
110
|
+
return walk(json.loads(json.dumps(self.body_template)))
|
|
111
|
+
|
|
112
|
+
def _parse(self, q: Question, payload: dict[str, Any]) -> Answer:
|
|
113
|
+
keys = q.option_keys
|
|
114
|
+
conf = _first_path(payload, self.fields.get("confidence", [["confidence"]]))
|
|
115
|
+
if q.type == "noul":
|
|
116
|
+
p = _first_path(payload, self.fields.get("p_true", [["probability"], ["p_true"]]))
|
|
117
|
+
return Answer.failed(q, "no probability") if p is None else finalize_answer(q, [float(p), 1 - float(p)], confidence=conf)
|
|
118
|
+
dist = _first_path(payload, self.fields.get("probs", [["probabilities"], ["distribution"]]))
|
|
119
|
+
if isinstance(dist, dict):
|
|
120
|
+
probs = [float(dist.get(k, 0.0)) for k in keys]
|
|
121
|
+
elif isinstance(dist, list):
|
|
122
|
+
probs = [float(x) for x in dist[: len(keys)]]
|
|
123
|
+
else:
|
|
124
|
+
return Answer.failed(q, "no distribution")
|
|
125
|
+
return finalize_answer(q, probs, confidence=conf)
|
|
126
|
+
|
|
127
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
128
|
+
body = self._render(request)
|
|
129
|
+
delay, last = 0.5, None
|
|
130
|
+
for _ in range(self.max_retries):
|
|
131
|
+
t0 = time.perf_counter()
|
|
132
|
+
try:
|
|
133
|
+
r = self._client.post(self.url, json=body, headers=self.headers)
|
|
134
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
135
|
+
if r.status_code in (429, 500, 502, 503, 504):
|
|
136
|
+
raise httpx.HTTPStatusError(str(r.status_code), request=r.request, response=r)
|
|
137
|
+
r.raise_for_status()
|
|
138
|
+
data = r.json()
|
|
139
|
+
break
|
|
140
|
+
except (httpx.TransportError, httpx.HTTPStatusError) as e:
|
|
141
|
+
last = e
|
|
142
|
+
time.sleep(delay + random.uniform(0, delay))
|
|
143
|
+
delay = min(delay * 2, 16)
|
|
144
|
+
else:
|
|
145
|
+
return DecisionResponse(answers={k: Answer.failed(q, "transport") for k, q in request.questions.items()},
|
|
146
|
+
latency=LatencyRecord(client_ms=float("nan"), timestamp=time.time()),
|
|
147
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id),
|
|
148
|
+
transport_error=str(last))
|
|
149
|
+
raw_answers = _dig(data, self.answers_path, {}) or {}
|
|
150
|
+
answers = {k: (self._parse(q, raw_answers[k]) if isinstance(raw_answers.get(k), dict) else Answer.failed(q, "missing answer"))
|
|
151
|
+
for k, q in request.questions.items()}
|
|
152
|
+
in_tok = _first_path(data, self.fields.get("input_tokens", [["usage", "prompt_tokens"], ["usage", "input_tokens"]]))
|
|
153
|
+
cost = (in_tok / 1e6 * self.price) if (in_tok and self.price) else None
|
|
154
|
+
return DecisionResponse(
|
|
155
|
+
answers=answers, latency=LatencyRecord(client_ms=ms, timestamp=time.time()),
|
|
156
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id,
|
|
157
|
+
model_id_returned=_first_path(data, self.fields.get("model_returned", [["model"]])),
|
|
158
|
+
generation_id=_first_path(data, self.fields.get("generation_id", [["id"]])),
|
|
159
|
+
billed_input_tokens=in_tok, cost_usd=cost),
|
|
160
|
+
raw=data,
|
|
161
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Hybrid baseline: a System One model decides; below a confidence threshold, escalate to a second
|
|
2
|
+
model (typically an LLM). Reports both the combined answer and which path was taken, so accuracy,
|
|
3
|
+
latency and cost can be plotted against escalation rate. This is the realistic production deployment
|
|
4
|
+
and the comparison vendors' marketing avoids."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
|
|
11
|
+
from .base import BaseAdapter, get_adapter, register
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register("hybrid_router")
|
|
15
|
+
class HybridRouterAdapter(BaseAdapter):
|
|
16
|
+
def __init__(self, model_id: str = "hybrid", primary: dict | None = None, fallback: dict | None = None,
|
|
17
|
+
threshold: float = 0.7, **tunables) -> None:
|
|
18
|
+
super().__init__(model_id, **tunables)
|
|
19
|
+
primary = dict(primary or {"adapter": "mock"})
|
|
20
|
+
fallback = dict(fallback or {"adapter": "mock", "skill": 0.95, "latency_ms": 1500.0})
|
|
21
|
+
self.primary = get_adapter(primary.pop("adapter"), **primary)
|
|
22
|
+
self.fallback = get_adapter(fallback.pop("adapter"), **fallback)
|
|
23
|
+
self.threshold = threshold
|
|
24
|
+
self.tunables.update({"threshold": threshold})
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def capabilities(self) -> ModelCapabilities:
|
|
28
|
+
p = self.primary.capabilities
|
|
29
|
+
return p.model_copy(update={"name": f"hybrid({p.name}->{self.fallback.capabilities.name})@{self.threshold}",
|
|
30
|
+
"deployment": "hosted" if "hosted" in (p.deployment, self.fallback.capabilities.deployment) else "local"})
|
|
31
|
+
|
|
32
|
+
def decide(self, request: DecisionRequest) -> DecisionResponse:
|
|
33
|
+
first = self.primary.decide(request)
|
|
34
|
+
low = [k for k, a in first.answers.items() if (not a.ok) or max(a.probs) < self.threshold]
|
|
35
|
+
if not low:
|
|
36
|
+
first.raw = {"path": "primary", "escalated": []}
|
|
37
|
+
return first
|
|
38
|
+
sub = DecisionRequest(state=request.state, questions={k: request.questions[k] for k in low}, meta=request.meta)
|
|
39
|
+
second = self.fallback.decide(sub)
|
|
40
|
+
answers = dict(first.answers)
|
|
41
|
+
answers.update(second.answers)
|
|
42
|
+
lat = LatencyRecord(client_ms=first.latency.client_ms + second.latency.client_ms, timestamp=time.time())
|
|
43
|
+
cost = (first.provider.cost_usd or 0.0) + (second.provider.cost_usd or 0.0)
|
|
44
|
+
return DecisionResponse(
|
|
45
|
+
answers=answers, latency=lat,
|
|
46
|
+
provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.capabilities.name, cost_usd=cost,
|
|
47
|
+
version_hash=f"{first.provider.version_hash}+{second.provider.version_hash}"),
|
|
48
|
+
raw={"path": "escalated", "escalated": low, "primary": first.raw, "fallback": second.raw},
|
|
49
|
+
)
|