thinkless 0.2.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 (63) hide show
  1. thinkless/__init__.py +63 -0
  2. thinkless/__main__.py +3 -0
  3. thinkless/_hub.py +84 -0
  4. thinkless/_json.py +33 -0
  5. thinkless/_version.py +1 -0
  6. thinkless/bench/__init__.py +22 -0
  7. thinkless/bench/intents.py +406 -0
  8. thinkless/bench/intents_report.py +87 -0
  9. thinkless/bench/metrics.py +115 -0
  10. thinkless/bench/report.py +123 -0
  11. thinkless/bench/support.py +342 -0
  12. thinkless/cli/__init__.py +1 -0
  13. thinkless/cli/main.py +595 -0
  14. thinkless/confidence.py +62 -0
  15. thinkless/data/pricing.toml +56 -0
  16. thinkless/data/viewer.html +411 -0
  17. thinkless/decision.py +185 -0
  18. thinkless/demo/__init__.py +1 -0
  19. thinkless/demo/support/__init__.py +19 -0
  20. thinkless/demo/support/agent.py +379 -0
  21. thinkless/demo/support/data/calibration.jsonl +48 -0
  22. thinkless/demo/support/data/scenarios.jsonl +53 -0
  23. thinkless/demo/support/data/world.json +78 -0
  24. thinkless/demo/support/questions.py +118 -0
  25. thinkless/demo/support/stack.py +93 -0
  26. thinkless/demo/support/world.py +165 -0
  27. thinkless/engine.py +605 -0
  28. thinkless/errors.py +13 -0
  29. thinkless/llm/__init__.py +47 -0
  30. thinkless/llm/anthropic.py +126 -0
  31. thinkless/llm/base.py +82 -0
  32. thinkless/llm/factory.py +91 -0
  33. thinkless/llm/local.py +168 -0
  34. thinkless/llm/openai_compat.py +165 -0
  35. thinkless/llm/openrouter.py +78 -0
  36. thinkless/llm/scripted.py +75 -0
  37. thinkless/logs.py +68 -0
  38. thinkless/pricing.py +100 -0
  39. thinkless/providers/__init__.py +43 -0
  40. thinkless/providers/base.py +108 -0
  41. thinkless/providers/gliner.py +194 -0
  42. thinkless/providers/hf.py +161 -0
  43. thinkless/providers/laya.py +121 -0
  44. thinkless/providers/llm.py +305 -0
  45. thinkless/providers/rules.py +181 -0
  46. thinkless/providers/systemone.py +147 -0
  47. thinkless/providers/wire.py +108 -0
  48. thinkless/py.typed +0 -0
  49. thinkless/questions.py +246 -0
  50. thinkless/settings.py +93 -0
  51. thinkless/tracing/__init__.py +23 -0
  52. thinkless/tracing/console.py +169 -0
  53. thinkless/tracing/otel.py +139 -0
  54. thinkless/tracing/sinks.py +142 -0
  55. thinkless/tracing/span.py +98 -0
  56. thinkless/tracing/summary.py +143 -0
  57. thinkless/tracing/tracer.py +159 -0
  58. thinkless/tracing/viewer.py +87 -0
  59. thinkless-0.2.0.dist-info/METADATA +357 -0
  60. thinkless-0.2.0.dist-info/RECORD +63 -0
  61. thinkless-0.2.0.dist-info/WHEEL +4 -0
  62. thinkless-0.2.0.dist-info/entry_points.txt +2 -0
  63. thinkless-0.2.0.dist-info/licenses/LICENSE +202 -0
thinkless/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """ThinkLess: the decision plane for AI agents.
2
+
3
+ Rules and small calibrated models make the routine decisions of an agent, a
4
+ generative model is reserved for the steps that need thought, and every step
5
+ is traced with its latency, confidence, tokens and cost.
6
+
7
+ Quick start::
8
+
9
+ from thinkless import Choice, Engine
10
+ from thinkless.providers import GLiNER, LLMDecider, Rules
11
+ from thinkless.llm import TransformersLLM
12
+
13
+ llm = TransformersLLM("Qwen/Qwen3-1.7B")
14
+ engine = Engine([Rules(), GLiNER(), LLMDecider(llm)], llm=llm, threshold=0.8)
15
+
16
+ intent = engine.decide(
17
+ "I was charged twice for order #4471, please refund one of them.",
18
+ Choice("What does the customer want?", options={
19
+ "refund": "wants money back",
20
+ "order_status": "asks where an order is",
21
+ "other": "anything else",
22
+ }),
23
+ )
24
+ print(intent.value, intent.confidence, intent.provider)
25
+ """
26
+
27
+ from ._version import __version__
28
+ from .decision import Answer, Attempt, Decision, Plane, Status, Usage
29
+ from .engine import Engine, Run
30
+ from .errors import ConfigurationError, ThinkLessError
31
+ from .logs import configure_logging
32
+ from .questions import Choice, Extract, Kind, Question, Score, YesNo
33
+ from .settings import load_env
34
+ from .tracing import ConsoleSink, JSONLSink, MemorySink, Tracer, TraceSummary, summarize, tool
35
+
36
+ __all__ = [
37
+ "Answer",
38
+ "Attempt",
39
+ "Choice",
40
+ "ConfigurationError",
41
+ "ConsoleSink",
42
+ "Decision",
43
+ "Engine",
44
+ "Extract",
45
+ "JSONLSink",
46
+ "Kind",
47
+ "MemorySink",
48
+ "Plane",
49
+ "Question",
50
+ "Run",
51
+ "Score",
52
+ "Status",
53
+ "ThinkLessError",
54
+ "TraceSummary",
55
+ "Tracer",
56
+ "Usage",
57
+ "YesNo",
58
+ "__version__",
59
+ "configure_logging",
60
+ "load_env",
61
+ "summarize",
62
+ "tool",
63
+ ]
thinkless/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from thinkless.cli.main import app
2
+
3
+ app()
thinkless/_hub.py ADDED
@@ -0,0 +1,84 @@
1
+ """Model downloads from the Hugging Face Hub, made reliable on Windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ from pathlib import Path
8
+
9
+ from .logs import get_logger
10
+
11
+ __all__ = ["DEFAULT_CHECKPOINTS", "cached_checkpoints", "ensure_downloaded"]
12
+
13
+ logger = get_logger("hub")
14
+
15
+ # What `thinkless demo` and the default providers download on first use.
16
+ DEFAULT_CHECKPOINTS = {
17
+ "fastino/gliner2.5-base-v1": "GLiNER provider",
18
+ "convaiinnovations/laya": "Laya provider",
19
+ "Qwen/Qwen3-1.7B": "local LLM (--llm local)",
20
+ }
21
+
22
+
23
+ # Weight formats ThinkLess never loads. Skipping them keeps the serial download
24
+ # as small as the one the model library would have made.
25
+ IGNORED = [
26
+ "*.onnx",
27
+ "onnx/*",
28
+ "*.msgpack",
29
+ "*.h5",
30
+ "*.ot",
31
+ "*.tflite",
32
+ "*.gguf",
33
+ "flax_model*",
34
+ "tf_model*",
35
+ "rust_model*",
36
+ "coreml/*",
37
+ "openvino/*",
38
+ ]
39
+
40
+
41
+ def _is_local_path(repo_id: str) -> bool:
42
+ return Path(repo_id).exists()
43
+
44
+
45
+ def ensure_downloaded(repo_id: str) -> None:
46
+ """Download ``repo_id`` into the Hugging Face cache before a library loads it.
47
+
48
+ On Windows the Hub client downloads files in parallel and can hit a race
49
+ when it creates cache symlinks (``WinError 1314``). Downloading serially
50
+ first avoids it; the model library then finds everything in the cache.
51
+ Elsewhere this is a no-op and the library downloads as usual. Local paths
52
+ and offline mode (``HF_HUB_OFFLINE=1``) are left alone.
53
+ """
54
+ if platform.system() != "Windows" or _is_local_path(repo_id):
55
+ return
56
+ if os.environ.get("HF_HUB_OFFLINE", "").strip() in ("1", "true", "True"):
57
+ return
58
+ try:
59
+ from huggingface_hub import snapshot_download
60
+ except ImportError: # pragma: no cover - installed with every local extra
61
+ return
62
+ try:
63
+ snapshot_download(repo_id, max_workers=1, ignore_patterns=IGNORED)
64
+ except Exception as exc: # the library's own loader reports the real error
65
+ logger.warning(
66
+ "pre-download of %s failed (%s); falling back to the library loader", repo_id, exc
67
+ )
68
+
69
+
70
+ def cached_checkpoints() -> dict[str, float | None]:
71
+ """Size in GB of each default checkpoint in the local cache, or ``None`` if absent."""
72
+ sizes: dict[str, float | None] = dict.fromkeys(DEFAULT_CHECKPOINTS)
73
+ try:
74
+ from huggingface_hub import scan_cache_dir
75
+ except ImportError:
76
+ return sizes
77
+ try:
78
+ info = scan_cache_dir()
79
+ except Exception:
80
+ return sizes
81
+ for repo in info.repos:
82
+ if repo.repo_id in sizes and repo.repo_type == "model":
83
+ sizes[repo.repo_id] = round(repo.size_on_disk / 1e9, 2)
84
+ return sizes
thinkless/_json.py ADDED
@@ -0,0 +1,33 @@
1
+ """JSON-safe conversion used by traces and reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+
12
+ __all__ = ["jsonable"]
13
+
14
+
15
+ def jsonable(value: Any, *, depth: int = 0) -> Any:
16
+ """Best-effort conversion of arbitrary values into JSON-safe data."""
17
+ if depth > 8:
18
+ return repr(value)
19
+ if value is None or isinstance(value, (bool, int, float, str)):
20
+ return value
21
+ if isinstance(value, enum.Enum):
22
+ return value.value
23
+ if isinstance(value, BaseModel):
24
+ return jsonable(value.model_dump(mode="json"), depth=depth + 1)
25
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
26
+ return jsonable(dataclasses.asdict(value), depth=depth + 1)
27
+ if isinstance(value, dict):
28
+ return {str(k): jsonable(v, depth=depth + 1) for k, v in value.items()}
29
+ if isinstance(value, (list, tuple, set, frozenset)):
30
+ return [jsonable(v, depth=depth + 1) for v in value]
31
+ if isinstance(value, Path):
32
+ return str(value)
33
+ return repr(value)
thinkless/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,22 @@
1
+ """Benchmarks and calibration."""
2
+
3
+ from .metrics import (
4
+ ThresholdPoint,
5
+ expected_calibration_error,
6
+ percentile,
7
+ recommend_threshold,
8
+ threshold_sweep,
9
+ )
10
+ from .support import ModeReport, SupportBenchmark, TicketResult, run_support_benchmark
11
+
12
+ __all__ = [
13
+ "ModeReport",
14
+ "SupportBenchmark",
15
+ "ThresholdPoint",
16
+ "TicketResult",
17
+ "expected_calibration_error",
18
+ "percentile",
19
+ "recommend_threshold",
20
+ "run_support_benchmark",
21
+ "threshold_sweep",
22
+ ]
@@ -0,0 +1,406 @@
1
+ """Intent classification benchmark on public datasets.
2
+
3
+ Measures each provider's accuracy, calibration and latency on one ``Choice``
4
+ question, then simulates the cascade: at every threshold, how often the small
5
+ models answer on their own, how accurate the combined system is, and how many
6
+ calls reach the LLM.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import io
13
+ import json
14
+ import random
15
+ import time
16
+ from collections.abc import Callable, Sequence
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import httpx
21
+ from pydantic import BaseModel, Field
22
+
23
+ from .._version import __version__
24
+ from ..engine import Engine
25
+ from ..providers.base import DecisionProvider
26
+ from ..questions import Choice, YesNo
27
+ from ..tracing import Tracer
28
+ from .metrics import (
29
+ ThresholdPoint,
30
+ expected_calibration_error,
31
+ percentile,
32
+ recommend_threshold,
33
+ threshold_sweep,
34
+ )
35
+ from .support import environment_info
36
+
37
+ __all__ = [
38
+ "DATASETS",
39
+ "CascadePoint",
40
+ "ChoiceEvaluation",
41
+ "IntentsBenchmark",
42
+ "ProviderEval",
43
+ "build_provider",
44
+ "evaluate_choice",
45
+ "evaluate_question",
46
+ "load_dataset_rows",
47
+ "run_intents_benchmark",
48
+ ]
49
+
50
+ BANKING77_TEST = "https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/master/banking_data/test.csv"
51
+
52
+ DATASETS: dict[str, dict[str, Any]] = {
53
+ "banking77": {
54
+ "title": "Banking77 (PolyAI), 77 banking intents",
55
+ "instructions": "What is the customer's banking request about?",
56
+ "source": "https://github.com/PolyAI-LDN/task-specific-datasets",
57
+ },
58
+ "clinc150": {
59
+ "title": "CLINC150 plus (clinc/clinc_oos), 150 intents and out of scope",
60
+ "instructions": "What does the user want?",
61
+ "source": "https://huggingface.co/datasets/clinc/clinc_oos",
62
+ "repo": "clinc/clinc_oos",
63
+ "config": "plus",
64
+ "label": "intent",
65
+ },
66
+ "emotion": {
67
+ "title": "Emotion (dair-ai/emotion), 6 emotions",
68
+ "instructions": "Which emotion does the text express?",
69
+ "source": "https://huggingface.co/datasets/dair-ai/emotion",
70
+ "repo": "dair-ai/emotion",
71
+ "config": "split",
72
+ "label": "label",
73
+ },
74
+ }
75
+
76
+
77
+ def _humanize(label: str) -> str:
78
+ return "out of scope" if label == "oos" else label.replace("_", " ")
79
+
80
+
81
+ def _cache_dir() -> Path:
82
+ path = Path.home() / ".cache" / "thinkless" / "datasets"
83
+ path.mkdir(parents=True, exist_ok=True)
84
+ return path
85
+
86
+
87
+ def load_dataset_rows(name: str) -> list[dict[str, str]]:
88
+ """Test split of a supported dataset as ``{"text", "label"}`` rows with readable labels."""
89
+ if name not in DATASETS:
90
+ raise ValueError(f"unknown dataset {name!r}; choose one of {', '.join(DATASETS)}")
91
+ if name == "banking77":
92
+ cached = _cache_dir() / "banking77-test.csv"
93
+ if not cached.exists():
94
+ response = httpx.get(BANKING77_TEST, timeout=60.0, follow_redirects=True)
95
+ response.raise_for_status()
96
+ cached.write_text(response.text, encoding="utf-8")
97
+ reader = csv.DictReader(io.StringIO(cached.read_text(encoding="utf-8")))
98
+ return [{"text": row["text"], "label": _humanize(row["category"])} for row in reader]
99
+ try:
100
+ from datasets import load_dataset
101
+ except ImportError as exc: # pragma: no cover - depends on the extra
102
+ raise ImportError(
103
+ f'The {name} dataset needs the bench extra: pip install "thinkless[bench]"'
104
+ ) from exc
105
+ spec = DATASETS[name]
106
+ dataset = load_dataset(spec["repo"], spec["config"], split="test")
107
+ names = dataset.features[spec["label"]].names
108
+ return [{"text": row["text"], "label": _humanize(names[row[spec["label"]]])} for row in dataset]
109
+
110
+
111
+ def build_provider(
112
+ name: str, *, llm_spec: str = "local", device: str = "auto", reasoning: str | None = None
113
+ ) -> DecisionProvider:
114
+ """A provider by short name: ``gliner``, ``laya``, ``llm`` or ``jev``."""
115
+ if name == "gliner":
116
+ from ..providers.gliner import GLiNER
117
+
118
+ return GLiNER(device=device)
119
+ if name == "laya":
120
+ from ..providers.laya import Laya
121
+
122
+ return Laya(device=device)
123
+ if name == "llm":
124
+ from ..llm import from_spec
125
+ from ..providers import LLMDecider
126
+
127
+ return LLMDecider(from_spec(llm_spec, device=device, reasoning=reasoning))
128
+ if name == "jev":
129
+ from ..providers import SystemOne
130
+
131
+ return SystemOne.jev()
132
+ raise ValueError(f"unknown provider {name!r}; choose gliner, laya, llm or jev")
133
+
134
+
135
+ class ChoiceEvaluation(BaseModel):
136
+ """One provider's answers to one question over a labeled set."""
137
+
138
+ provider: str
139
+ predictions: list[Any]
140
+ confidences: list[float | None]
141
+ correct: list[bool]
142
+ latencies_ms: list[float]
143
+ input_tokens: list[int]
144
+ costs_usd: list[float] = Field(default_factory=list)
145
+
146
+ @property
147
+ def accuracy(self) -> float:
148
+ return sum(self.correct) / len(self.correct) if self.correct else 0.0
149
+
150
+ @property
151
+ def latency_p50_ms(self) -> float:
152
+ return percentile(self.latencies_ms, 50)
153
+
154
+
155
+ def evaluate_question(
156
+ provider: DecisionProvider,
157
+ question: Choice | YesNo,
158
+ rows: Sequence[dict[str, Any]],
159
+ *,
160
+ text_field: str = "text",
161
+ label_field: str = "label",
162
+ progress: Callable[[int, int], None] | None = None,
163
+ ) -> ChoiceEvaluation:
164
+ """Ask ``question`` about every row and score the answer against the row's label.
165
+
166
+ For a ``Choice`` the label is an option; for a ``YesNo`` it is a boolean.
167
+ """
168
+ provider.warmup()
169
+ engine = Engine([provider], threshold=0.0, tracer=Tracer())
170
+ predictions: list[Any] = []
171
+ confidences: list[float | None] = []
172
+ correct: list[bool] = []
173
+ latencies: list[float] = []
174
+ tokens: list[int] = []
175
+ costs: list[float] = []
176
+ for index, row in enumerate(rows):
177
+ truth = row[label_field]
178
+ if isinstance(question, YesNo):
179
+ truth = (
180
+ truth
181
+ if isinstance(truth, bool)
182
+ else str(truth).strip().lower() in ("true", "yes", "1")
183
+ )
184
+ started = time.perf_counter()
185
+ decision = engine.decide(row[text_field], question)
186
+ latencies.append((time.perf_counter() - started) * 1000.0)
187
+ predictions.append(decision.value)
188
+ confidences.append(decision.confidence)
189
+ correct.append(decision.value == truth)
190
+ tokens.append(decision.usage.input_tokens)
191
+ costs.append(decision.cost_usd)
192
+ if progress is not None:
193
+ progress(index + 1, len(rows))
194
+ return ChoiceEvaluation(
195
+ provider=provider.name,
196
+ predictions=predictions,
197
+ confidences=confidences,
198
+ correct=correct,
199
+ latencies_ms=latencies,
200
+ input_tokens=tokens,
201
+ costs_usd=costs,
202
+ )
203
+
204
+
205
+ def evaluate_choice(
206
+ provider: DecisionProvider,
207
+ question: Choice,
208
+ rows: Sequence[dict[str, Any]],
209
+ *,
210
+ progress: Callable[[int, int], None] | None = None,
211
+ ) -> ChoiceEvaluation:
212
+ """Shorthand for :func:`evaluate_question` on ``{"text", "label"}`` rows."""
213
+ return evaluate_question(provider, question, rows, progress=progress)
214
+
215
+
216
+ class ProviderEval(BaseModel):
217
+ provider: str
218
+ accuracy: float
219
+ ece: float | None
220
+ latency_p50_ms: float
221
+ latency_p95_ms: float
222
+ mean_input_tokens: float
223
+ cost_per_1k: float = 0.0
224
+ abstained: int
225
+ sweep: list[ThresholdPoint] = Field(default_factory=list)
226
+ recommended: ThresholdPoint | None = None
227
+
228
+
229
+ class CascadePoint(BaseModel):
230
+ """The simulated cascade (small models in order, then the LLM) at one threshold."""
231
+
232
+ threshold: float
233
+ accuracy: float
234
+ llm_share: float
235
+ mean_latency_ms: float
236
+ cost_per_1k: float = 0.0
237
+ answered_by: dict[str, float]
238
+
239
+
240
+ class IntentsBenchmark(BaseModel):
241
+ thinkless_version: str = __version__
242
+ created_at: str
243
+ dataset: str
244
+ dataset_title: str
245
+ source: str
246
+ examples: int
247
+ labels: int
248
+ seed: int
249
+ llm: str
250
+ target_accuracy: float
251
+ environment: dict[str, Any]
252
+ providers: dict[str, ProviderEval]
253
+ cascade: list[CascadePoint]
254
+
255
+
256
+ def _cost_at(evaluation: ChoiceEvaluation, index: int) -> float:
257
+ return evaluation.costs_usd[index] if index < len(evaluation.costs_usd) else 0.0
258
+
259
+
260
+ def _cascade(
261
+ evaluations: dict[str, ChoiceEvaluation], order: Sequence[str], fallback: str | None
262
+ ) -> list[CascadePoint]:
263
+ small = [p for p in order if p != fallback]
264
+ n = len(next(iter(evaluations.values())).correct)
265
+ points = []
266
+ for threshold in [round(x * 0.05, 2) for x in range(0, 20)] + [0.97, 0.99, 1.01]:
267
+ hits = 0
268
+ llm_calls = 0
269
+ latency = 0.0
270
+ cost = 0.0
271
+ answered: dict[str, int] = dict.fromkeys([*small, fallback or "unresolved"], 0)
272
+ for i in range(n):
273
+ chosen = None
274
+ for name in small:
275
+ latency += evaluations[name].latencies_ms[i]
276
+ cost += _cost_at(evaluations[name], i)
277
+ confidence = evaluations[name].confidences[i]
278
+ if confidence is not None and confidence >= threshold:
279
+ chosen = name
280
+ break
281
+ if chosen is None and fallback is not None:
282
+ chosen = fallback
283
+ llm_calls += 1
284
+ latency += evaluations[fallback].latencies_ms[i]
285
+ cost += _cost_at(evaluations[fallback], i)
286
+ if chosen is None:
287
+ answered["unresolved"] += 1
288
+ continue
289
+ answered[chosen] += 1
290
+ hits += int(evaluations[chosen].correct[i])
291
+ points.append(
292
+ CascadePoint(
293
+ threshold=threshold,
294
+ accuracy=round(hits / n, 4),
295
+ llm_share=round(llm_calls / n, 4),
296
+ mean_latency_ms=round(latency / n, 2),
297
+ cost_per_1k=round(cost / n * 1000, 5),
298
+ answered_by={k: round(v / n, 4) for k, v in answered.items()},
299
+ )
300
+ )
301
+ return points
302
+
303
+
304
+ def _resolved_llm(spec: str) -> str:
305
+ from ..llm.factory import DEFAULT_LOCAL_MODEL
306
+
307
+ backend, _, model = spec.partition(":")
308
+ if backend == "local" and not model:
309
+ return f"local:{DEFAULT_LOCAL_MODEL}"
310
+ return spec
311
+
312
+
313
+ def run_intents_benchmark(
314
+ dataset: str,
315
+ *,
316
+ providers: Sequence[str] = ("gliner", "laya", "llm"),
317
+ llm_spec: str = "local",
318
+ device: str = "auto",
319
+ reasoning: str | None = None,
320
+ limit: int = 500,
321
+ seed: int = 13,
322
+ target_accuracy: float = 0.95,
323
+ output_dir: str | Path | None = None,
324
+ progress: Callable[[str], None] | None = None,
325
+ ) -> IntentsBenchmark:
326
+ """Evaluate providers on a dataset sample and simulate the cascade.
327
+
328
+ The sample is drawn with a fixed seed so runs are comparable. When ``llm``
329
+ is among the providers it acts as the cascade's fallback.
330
+ """
331
+ say = progress or (lambda message: None)
332
+ rows = load_dataset_rows(dataset)
333
+ rng = random.Random(seed)
334
+ sample = rng.sample(rows, min(limit, len(rows)))
335
+ labels = sorted({row["label"] for row in rows})
336
+ question = Choice(DATASETS[dataset]["instructions"], options=labels, name="label")
337
+ say(f"{dataset}: {len(sample)} of {len(rows)} test examples, {len(labels)} labels")
338
+
339
+ evaluations: dict[str, ChoiceEvaluation] = {}
340
+ reports: dict[str, ProviderEval] = {}
341
+ for name in providers:
342
+ provider = build_provider(name, llm_spec=llm_spec, device=device, reasoning=reasoning)
343
+ started = time.perf_counter()
344
+
345
+ def tick(done: int, total: int, name: str = name, started: float = started) -> None:
346
+ if done % 50 == 0 or done == total:
347
+ say(f"{name}: {done}/{total} {time.perf_counter() - started:.0f}s")
348
+
349
+ evaluation = evaluate_choice(provider, question, sample, progress=tick)
350
+ evaluations[name] = evaluation
351
+ calibrated = all(c is not None for c in evaluation.confidences)
352
+ confidences = [c if c is not None else 1.0 for c in evaluation.confidences]
353
+ sweep = threshold_sweep(confidences, evaluation.correct) if calibrated else []
354
+ reports[name] = ProviderEval(
355
+ provider=name,
356
+ accuracy=round(evaluation.accuracy, 4),
357
+ ece=round(expected_calibration_error(confidences, evaluation.correct), 4)
358
+ if calibrated
359
+ else None,
360
+ latency_p50_ms=round(evaluation.latency_p50_ms, 2),
361
+ latency_p95_ms=round(percentile(evaluation.latencies_ms, 95), 2),
362
+ mean_input_tokens=round(sum(evaluation.input_tokens) / len(sample), 1),
363
+ cost_per_1k=round(sum(evaluation.costs_usd) / len(sample) * 1000, 5),
364
+ abstained=sum(p is None for p in evaluation.predictions),
365
+ sweep=sweep,
366
+ recommended=recommend_threshold(sweep, target_accuracy) if sweep else None,
367
+ )
368
+ provider.close()
369
+
370
+ fallback = "llm" if "llm" in providers else None
371
+ result = IntentsBenchmark(
372
+ created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
373
+ dataset=dataset,
374
+ dataset_title=DATASETS[dataset]["title"],
375
+ source=DATASETS[dataset]["source"],
376
+ examples=len(sample),
377
+ labels=len(labels),
378
+ seed=seed,
379
+ llm=(
380
+ _resolved_llm(llm_spec)
381
+ + ("" if reasoning in (None, "default") else f" (reasoning {reasoning})")
382
+ )
383
+ if "llm" in providers
384
+ else "not used",
385
+ target_accuracy=target_accuracy,
386
+ environment=environment_info(),
387
+ providers=reports,
388
+ cascade=_cascade(evaluations, list(providers), fallback),
389
+ )
390
+ if output_dir is not None:
391
+ out = Path(output_dir)
392
+ out.mkdir(parents=True, exist_ok=True)
393
+ (out / "results.json").write_text(
394
+ json.dumps(result.model_dump(mode="json"), indent=2), encoding="utf-8"
395
+ )
396
+ with (out / "predictions.jsonl").open("w", encoding="utf-8") as handle:
397
+ for i, row in enumerate(sample):
398
+ record: dict[str, Any] = {"text": row["text"], "label": row["label"]}
399
+ for name, evaluation in evaluations.items():
400
+ record[name] = {
401
+ "prediction": evaluation.predictions[i],
402
+ "confidence": evaluation.confidences[i],
403
+ "latency_ms": round(evaluation.latencies_ms[i], 2),
404
+ }
405
+ handle.write(json.dumps(record, ensure_ascii=False) + "\n")
406
+ return result