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.
Files changed (72) hide show
  1. sys1bench/__init__.py +3 -0
  2. sys1bench/adapters/__init__.py +19 -0
  3. sys1bench/adapters/base.py +159 -0
  4. sys1bench/adapters/embed_knn.py +61 -0
  5. sys1bench/adapters/encoder_finetuned.py +114 -0
  6. sys1bench/adapters/generic_http.py +161 -0
  7. sys1bench/adapters/hybrid_router.py +49 -0
  8. sys1bench/adapters/jev_openrouter.py +172 -0
  9. sys1bench/adapters/jev_typesafe.py +165 -0
  10. sys1bench/adapters/laya_local.py +192 -0
  11. sys1bench/adapters/llm_constrained.py +104 -0
  12. sys1bench/adapters/majority_prior.py +53 -0
  13. sys1bench/adapters/mock.py +77 -0
  14. sys1bench/adapters/nli_zeroshot.py +50 -0
  15. sys1bench/adapters/regex_keyword.py +70 -0
  16. sys1bench/analysis/__init__.py +4 -0
  17. sys1bench/analysis/arms.py +46 -0
  18. sys1bench/analysis/decision_value.py +43 -0
  19. sys1bench/analysis/decomposition.py +47 -0
  20. sys1bench/analysis/meta_eval.py +67 -0
  21. sys1bench/analysis/stats.py +76 -0
  22. sys1bench/cli.py +434 -0
  23. sys1bench/data/__init__.py +37 -0
  24. sys1bench/data/canary/canary.jsonl +200 -0
  25. sys1bench/data/framings/guardrail_intent.yaml +21 -0
  26. sys1bench/data/framings/log_triage.yaml +20 -0
  27. sys1bench/data/framings/phishing_email.yaml +25 -0
  28. sys1bench/data/framings/policy_compliance.yaml +18 -0
  29. sys1bench/data/framings/support_tickets.yaml +41 -0
  30. sys1bench/framing/__init__.py +10 -0
  31. sys1bench/framing/expand.py +214 -0
  32. sys1bench/framing/perturb.py +67 -0
  33. sys1bench/generators/__init__.py +10 -0
  34. sys1bench/generators/base.py +105 -0
  35. sys1bench/generators/guardrail_intent.py +95 -0
  36. sys1bench/generators/log_triage.py +100 -0
  37. sys1bench/generators/multilingual_tickets.py +84 -0
  38. sys1bench/generators/phishing_email.py +127 -0
  39. sys1bench/generators/policy_compliance.py +90 -0
  40. sys1bench/generators/rag_relevance.py +122 -0
  41. sys1bench/generators/support_tickets.py +212 -0
  42. sys1bench/metrics/__init__.py +9 -0
  43. sys1bench/metrics/calibration.py +164 -0
  44. sys1bench/metrics/consistency.py +71 -0
  45. sys1bench/metrics/decision_value.py +43 -0
  46. sys1bench/metrics/efficiency.py +35 -0
  47. sys1bench/metrics/ordinal.py +80 -0
  48. sys1bench/metrics/robustness.py +46 -0
  49. sys1bench/metrics/selective.py +67 -0
  50. sys1bench/report/__init__.py +1 -0
  51. sys1bench/report/dashboard.py +177 -0
  52. sys1bench/report/latex.py +40 -0
  53. sys1bench/report/plots.py +143 -0
  54. sys1bench/report/results_doc.py +237 -0
  55. sys1bench/report/scorecard.py +170 -0
  56. sys1bench/runners/__init__.py +2 -0
  57. sys1bench/runners/benchmark_runner.py +149 -0
  58. sys1bench/runners/cache.py +53 -0
  59. sys1bench/runners/canary.py +43 -0
  60. sys1bench/runners/hybrid_sweep.py +37 -0
  61. sys1bench/runners/noul_consistency.py +121 -0
  62. sys1bench/runners/ordinal_probes.py +90 -0
  63. sys1bench/runners/robustness.py +112 -0
  64. sys1bench/runners/suite.py +183 -0
  65. sys1bench/runners/sweeps.py +190 -0
  66. sys1bench/schemas.py +274 -0
  67. sys1bench-0.3.0.dist-info/METADATA +195 -0
  68. sys1bench-0.3.0.dist-info/RECORD +72 -0
  69. sys1bench-0.3.0.dist-info/WHEEL +5 -0
  70. sys1bench-0.3.0.dist-info/entry_points.txt +2 -0
  71. sys1bench-0.3.0.dist-info/licenses/LICENSE +202 -0
  72. sys1bench-0.3.0.dist-info/top_level.txt +1 -0
sys1bench/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sys1bench: benchmark harness for typed System One decision models."""
2
+
3
+ __version__ = "0.3.0"
@@ -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
+ )