ellements 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 (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. ellements-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,342 @@
1
+ """BenchmarkModel adapter and orchestration for `lm-evaluation-harness`.
2
+
3
+ Wraps an :class:`~ellements.core.LLMClient` (optionally with a
4
+ :class:`~ellements.execution.Strategy`) as an ``lm_eval.api.model.LM``
5
+ so any prompting strategy can be evaluated against standard benchmarks
6
+ (MMLU, GSM8K, HellaSwag, ARC, HumanEval, …).
7
+
8
+ Key design points:
9
+
10
+ - **Single persistent event loop owned by the harness.** All async LLM
11
+ calls are dispatched onto one background thread running a single
12
+ ``asyncio`` loop. No per-call ``asyncio.run`` and no thread-pool
13
+ re-entry. The loop is closed via :meth:`BenchmarkModel.shutdown` or
14
+ by using the model as a context manager.
15
+ - **Provider prefix is preserved.** The full ``"openai/gpt-4o"`` (or
16
+ ``"anthropic/..."``, etc.) is passed unchanged to ``LLMClient``;
17
+ litellm needs the prefix for routing.
18
+ - **Hard-fail on missing log-probabilities.** When a task requires
19
+ ``loglikelihood`` and the configured model does not return token
20
+ log-probs, the harness raises :class:`LogprobsUnsupportedError`
21
+ immediately rather than silently returning ``0.0``.
22
+ - **Real per-sample progress.** ``on_progress`` fires before each task
23
+ starts (per strategy/task pair) so callers can render a progress bar
24
+ that actually reflects work happening.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ import threading
32
+ from collections.abc import Callable, Coroutine, Iterable
33
+ from typing import Any, TypeVar
34
+
35
+ from ellements.core import LLMClient, LogprobsUnsupportedError
36
+ from ellements.execution import Strategy, StrategyConfigInput
37
+
38
+ try:
39
+ from lm_eval.api.model import LM
40
+ except ImportError as exc: # pragma: no cover
41
+ raise ImportError(
42
+ "lm-evaluation-harness is required for benchmarking. "
43
+ "Install it with: pip install 'ellements[benchmarking]'"
44
+ ) from exc
45
+
46
+ from .results import BenchmarkComparison
47
+
48
+ _logger = logging.getLogger(__name__)
49
+ T = TypeVar("T")
50
+
51
+ OnBenchmarkProgress = Callable[[str, str, int, int], None]
52
+ """Callback signature: ``(strategy_name, task_name, current_idx, total)``."""
53
+
54
+
55
+ class _LoopRunner:
56
+ """Owns a persistent asyncio event loop on a dedicated thread."""
57
+
58
+ def __init__(self) -> None:
59
+ self._loop = asyncio.new_event_loop()
60
+ self._thread = threading.Thread(
61
+ target=self._loop.run_forever,
62
+ name="ellements-benchmarking-loop",
63
+ daemon=True,
64
+ )
65
+ self._thread.start()
66
+
67
+ def submit(self, coro: Coroutine[Any, Any, T]) -> T:
68
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
69
+ return future.result()
70
+
71
+ def close(self) -> None:
72
+ if not self._loop.is_closed():
73
+ self._loop.call_soon_threadsafe(self._loop.stop)
74
+ self._thread.join(timeout=5.0)
75
+ self._loop.close()
76
+
77
+
78
+ class BenchmarkModel(LM): # type: ignore[misc]
79
+ """Wraps an :class:`LLMClient` (optionally with a :class:`Strategy`)
80
+ as an ``lm_eval.api.model.LM``.
81
+
82
+ Args:
83
+ client: The LLM client used for generation.
84
+ strategy: Optional execution strategy. When set, ``generate_until``
85
+ routes through ``strategy.execute()``. Strategies do **not**
86
+ affect ``loglikelihood`` — those are an intrinsic model
87
+ property.
88
+ strategy_config: Extra config forwarded to ``strategy.execute()``.
89
+ requires_logprobs: When True, the harness validates at startup
90
+ that the underlying model returns token log-probabilities,
91
+ raising :class:`LogprobsUnsupportedError` if not.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ client: LLMClient,
97
+ strategy: Strategy | None = None,
98
+ strategy_config: StrategyConfigInput = None,
99
+ *,
100
+ requires_logprobs: bool = False,
101
+ ) -> None:
102
+ super().__init__()
103
+ self._client = client
104
+ self._strategy = strategy
105
+ self._strategy_config: StrategyConfigInput = strategy_config or {}
106
+ self._loop = _LoopRunner()
107
+ self._closed = False
108
+
109
+ if requires_logprobs:
110
+ self._verify_logprobs_support()
111
+
112
+ def __enter__(self) -> BenchmarkModel:
113
+ return self
114
+
115
+ def __exit__(self, *_exc: object) -> None:
116
+ self.shutdown()
117
+
118
+ def shutdown(self) -> None:
119
+ """Shut down the persistent event loop. Idempotent."""
120
+ if not self._closed:
121
+ self._loop.close()
122
+ self._closed = True
123
+
124
+ # ── generate_until (generative benchmarks) ───────────────────────
125
+
126
+ def generate_until(self, requests: list[Any]) -> list[str]:
127
+ results: list[str] = []
128
+ for req in requests:
129
+ context, gen_kwargs = req.args
130
+ stop_seqs: Iterable[str] = gen_kwargs.get("until", []) or []
131
+
132
+ if self._strategy is not None:
133
+ output = self._loop.submit(self._strategy_call(context))
134
+ else:
135
+ output = self._loop.submit(self._direct_call(context))
136
+
137
+ for stop in stop_seqs:
138
+ if stop in output:
139
+ output = output[: output.index(stop)]
140
+
141
+ results.append(output)
142
+ return results
143
+
144
+ async def _direct_call(self, prompt: str) -> str:
145
+ return await self._client.complete(
146
+ [{"role": "user", "content": prompt}]
147
+ )
148
+
149
+ async def _strategy_call(self, prompt: str) -> str:
150
+ assert self._strategy is not None
151
+ result = await self._strategy.execute(
152
+ prompts={"default": prompt},
153
+ client=self._client,
154
+ tools=None,
155
+ config=self._strategy_config,
156
+ )
157
+ return result.output
158
+
159
+ # ── loglikelihood (multiple-choice benchmarks) ───────────────────
160
+
161
+ def loglikelihood(self, requests: list[Any]) -> list[tuple[float, bool]]:
162
+ """Compute log-likelihoods. Hard-fails when not supported."""
163
+ results: list[tuple[float, bool]] = []
164
+ for req in requests:
165
+ context, continuation = req.args
166
+ ll, is_greedy = self._loop.submit(
167
+ self._loglikelihood_call(context, continuation)
168
+ )
169
+ results.append((ll, is_greedy))
170
+ return results
171
+
172
+ def loglikelihood_rolling(self, requests: list[Any]) -> list[float]:
173
+ results: list[float] = []
174
+ for req in requests:
175
+ (text,) = req.args
176
+ ll, _ = self._loop.submit(self._loglikelihood_call("", text))
177
+ results.append(ll)
178
+ return results
179
+
180
+ async def _loglikelihood_call(
181
+ self, context: str, continuation: str
182
+ ) -> tuple[float, bool]:
183
+ return await self._client.loglikelihood(
184
+ context=context,
185
+ continuation=continuation,
186
+ max_tokens=max(1, len(continuation.split()) * 3),
187
+ )
188
+
189
+ def _verify_logprobs_support(self) -> None:
190
+ """Make a one-token probe call; raise if logprobs aren't returned."""
191
+ try:
192
+ self._loop.submit(self._loglikelihood_call("Hello", " world"))
193
+ except LogprobsUnsupportedError:
194
+ raise
195
+ except Exception as exc:
196
+ raise LogprobsUnsupportedError(
197
+ f"Probe loglikelihood call failed for model {self._client.model!r}: {exc}"
198
+ ) from exc
199
+
200
+
201
+ # ── public convenience functions ─────────────────────────────────────
202
+
203
+
204
+ def _tasks_require_logprobs(tasks: list[str]) -> bool:
205
+ """Best-effort introspection of lm-eval tasks for loglikelihood output type."""
206
+ try:
207
+ from lm_eval import tasks as lm_tasks
208
+ except ImportError: # pragma: no cover
209
+ return False
210
+
211
+ try:
212
+ task_dict = lm_tasks.get_task_dict(tasks)
213
+ except Exception: # pragma: no cover
214
+ return False
215
+
216
+ for value in task_dict.values():
217
+ output_type = getattr(value, "OUTPUT_TYPE", None) or getattr(
218
+ getattr(value, "_config", None), "output_type", None
219
+ )
220
+ if isinstance(output_type, str) and output_type.startswith(
221
+ "loglikelihood"
222
+ ):
223
+ return True
224
+ if isinstance(output_type, str) and output_type == "multiple_choice":
225
+ return True
226
+ return False
227
+
228
+
229
+ def run_benchmark(
230
+ *,
231
+ model: str,
232
+ strategy: Strategy | None = None,
233
+ strategy_config: StrategyConfigInput = None,
234
+ tasks: list[str],
235
+ num_fewshot: int | None = None,
236
+ limit: int | float | None = None,
237
+ batch_size: int | None = 1,
238
+ on_progress: OnBenchmarkProgress | None = None,
239
+ strategy_name: str = "default",
240
+ client: LLMClient | None = None,
241
+ **kwargs: Any,
242
+ ) -> dict[str, Any]:
243
+ """Run lm-eval benchmarks, optionally through an ellements strategy.
244
+
245
+ Args:
246
+ model: Litellm-format model id (e.g. ``"openai/gpt-4o"``). The
247
+ full provider prefix is preserved.
248
+ strategy: Optional :class:`Strategy`; routes generative tasks
249
+ through ``strategy.execute()``.
250
+ strategy_config: Extra config forwarded to the strategy.
251
+ tasks: lm-eval task names (required and non-empty).
252
+ num_fewshot: Number of few-shot examples.
253
+ limit: Sample limit per task (int = count; float in [0,1] = ratio).
254
+ batch_size: Batch size for lm-eval (defaults to 1 for API models).
255
+ on_progress: Fires before each task starts.
256
+ strategy_name: Logical label used in progress callbacks.
257
+ client: Optional pre-built :class:`LLMClient`. Useful for sharing
258
+ observers or providing API keys/headers. When None, one is
259
+ constructed from *model*.
260
+
261
+ Returns:
262
+ The raw lm-eval results dict.
263
+ """
264
+ from lm_eval.evaluator import simple_evaluate
265
+
266
+ if not tasks:
267
+ raise ValueError("tasks must be a non-empty list (e.g. ['gsm8k'])")
268
+
269
+ requires_logprobs = _tasks_require_logprobs(tasks)
270
+
271
+ owned_client = client is None
272
+ llm_client = client or LLMClient(model=model)
273
+
274
+ with BenchmarkModel(
275
+ client=llm_client,
276
+ strategy=strategy,
277
+ strategy_config=strategy_config,
278
+ requires_logprobs=requires_logprobs,
279
+ ) as benchmark_model:
280
+ for i, task in enumerate(tasks):
281
+ if on_progress is not None:
282
+ on_progress(strategy_name, task, i, len(tasks))
283
+
284
+ results = simple_evaluate(
285
+ model=benchmark_model,
286
+ tasks=tasks,
287
+ num_fewshot=num_fewshot,
288
+ limit=limit,
289
+ batch_size=batch_size,
290
+ **kwargs,
291
+ )
292
+
293
+ if owned_client:
294
+ _logger.debug("BenchmarkModel finished; owned client released.")
295
+ typed_results: dict[str, Any] = results
296
+ return typed_results
297
+
298
+
299
+ def compare_benchmarks(
300
+ *,
301
+ model: str,
302
+ strategies: dict[str, Strategy | None],
303
+ tasks: list[str],
304
+ num_fewshot: int | None = None,
305
+ limit: int | float | None = None,
306
+ batch_size: int | None = 1,
307
+ on_progress: OnBenchmarkProgress | None = None,
308
+ client: LLMClient | None = None,
309
+ **kwargs: Any,
310
+ ) -> BenchmarkComparison:
311
+ """Run the same benchmarks across multiple strategies and compare results."""
312
+ if not tasks:
313
+ raise ValueError("tasks must be a non-empty list (e.g. ['gsm8k'])")
314
+
315
+ all_results: dict[str, dict[str, Any]] = {}
316
+
317
+ for name, strategy in strategies.items():
318
+ _logger.info("Running benchmarks for strategy: %s", name)
319
+ all_results[name] = run_benchmark(
320
+ model=model,
321
+ strategy=strategy,
322
+ tasks=tasks,
323
+ num_fewshot=num_fewshot,
324
+ limit=limit,
325
+ batch_size=batch_size,
326
+ on_progress=on_progress,
327
+ strategy_name=name,
328
+ client=client,
329
+ **kwargs,
330
+ )
331
+
332
+ return BenchmarkComparison(
333
+ model=model, tasks=tasks, results=all_results
334
+ )
335
+
336
+
337
+ __all__ = [
338
+ "BenchmarkModel",
339
+ "OnBenchmarkProgress",
340
+ "compare_benchmarks",
341
+ "run_benchmark",
342
+ ]
File without changes
@@ -0,0 +1,173 @@
1
+ """Benchmark comparison results with formatting and export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class BenchmarkComparison:
12
+ """Holds results from :func:`compare_benchmarks` and provides
13
+ formatting, comparison, and export utilities.
14
+
15
+ Parameters
16
+ ----------
17
+ model : str
18
+ The model used across all strategies.
19
+ tasks : list[str]
20
+ The benchmark tasks that were run.
21
+ results : dict[str, dict]
22
+ Map of strategy name → raw lm-eval results dict.
23
+ """
24
+
25
+ model: str
26
+ tasks: list[str]
27
+ results: dict[str, dict[str, Any]]
28
+
29
+ # -- Extraction helpers ------------------------------------------------
30
+
31
+ def _extract_scores(self) -> dict[str, dict[str, float | None]]:
32
+ """Extract metric scores per strategy per task.
33
+
34
+ Returns ``{strategy: {task_metric: score}}``.
35
+ """
36
+ scores: dict[str, dict[str, float | None]] = {}
37
+ for strategy_name, raw in self.results.items():
38
+ strategy_scores: dict[str, float | None] = {}
39
+ task_results = raw.get("results", {})
40
+ for task_name, metrics in task_results.items():
41
+ for metric_key, value in metrics.items():
42
+ if metric_key.endswith(",none"):
43
+ metric_key = metric_key[: -len(",none")]
44
+ if isinstance(value, (int, float)):
45
+ key = f"{task_name}/{metric_key}"
46
+ strategy_scores[key] = round(float(value), 4)
47
+ scores[strategy_name] = strategy_scores
48
+ return scores
49
+
50
+ # -- Formatting --------------------------------------------------------
51
+
52
+ def print_table(self) -> str:
53
+ """Print a formatted comparison table and return it as a string.
54
+
55
+ Rows = metric (task/metric_name), Columns = strategies.
56
+ """
57
+ scores = self._extract_scores()
58
+ strategies = list(scores.keys())
59
+
60
+ # Collect all unique metric keys across strategies
61
+ all_metrics: list[str] = []
62
+ seen: set[str] = set()
63
+ for strat_scores in scores.values():
64
+ for k in strat_scores:
65
+ if k not in seen:
66
+ all_metrics.append(k)
67
+ seen.add(k)
68
+
69
+ if not all_metrics:
70
+ msg = "(No benchmark results to display)"
71
+ print(msg)
72
+ return msg
73
+
74
+ # Column widths
75
+ metric_col_width = max(len(m) for m in all_metrics)
76
+ metric_col_width = max(metric_col_width, len("Metric"))
77
+ strat_col_width = max(max((len(s) for s in strategies), default=8), 10)
78
+
79
+ # Header
80
+ header = f"{'Metric':<{metric_col_width}}"
81
+ for s in strategies:
82
+ header += f" {s:>{strat_col_width}}"
83
+ separator = "-" * len(header)
84
+
85
+ lines = [
86
+ f"Model: {self.model}",
87
+ f"Tasks: {', '.join(self.tasks)}",
88
+ "",
89
+ header,
90
+ separator,
91
+ ]
92
+
93
+ # Find best score per metric for highlighting
94
+ for metric in all_metrics:
95
+ vals = {s: scores[s].get(metric) for s in strategies}
96
+ best_val = max(
97
+ (v for v in vals.values() if v is not None), default=None
98
+ )
99
+
100
+ row = f"{metric:<{metric_col_width}}"
101
+ for s in strategies:
102
+ v = vals[s]
103
+ if v is None:
104
+ cell = "—"
105
+ elif v == best_val and len(strategies) > 1:
106
+ cell = f"{v:.4f} ★"
107
+ else:
108
+ cell = f"{v:.4f}"
109
+ row += f" {cell:>{strat_col_width}}"
110
+ lines.append(row)
111
+
112
+ lines.append(separator)
113
+ table = "\n".join(lines)
114
+ print(table)
115
+ return table
116
+
117
+ def best_strategy(self, metric: str) -> str | None:
118
+ """Return the strategy name with the highest score for *metric*.
119
+
120
+ Parameters
121
+ ----------
122
+ metric : str
123
+ Metric key like ``"gsm8k/acc"`` or ``"mmlu/acc"``.
124
+ """
125
+ scores = self._extract_scores()
126
+ best_name: str | None = None
127
+ best_val: float | None = None
128
+ for strat, strat_scores in scores.items():
129
+ val = strat_scores.get(metric)
130
+ if val is not None and (best_val is None or val > best_val):
131
+ best_val = val
132
+ best_name = strat
133
+ return best_name
134
+
135
+ # -- Export ------------------------------------------------------------
136
+
137
+ def to_dict(self) -> dict[str, Any]:
138
+ """Return a JSON-serialisable dictionary."""
139
+ return {
140
+ "model": self.model,
141
+ "tasks": self.tasks,
142
+ "scores": self._extract_scores(),
143
+ }
144
+
145
+ def to_json(self, path: str) -> None:
146
+ """Write comparison results to a JSON file."""
147
+ with open(path, "w") as f:
148
+ json.dump(self.to_dict(), f, indent=2)
149
+
150
+ def to_csv(self, path: str) -> None:
151
+ """Write comparison results to a CSV file."""
152
+ import csv
153
+
154
+ scores = self._extract_scores()
155
+ strategies = list(scores.keys())
156
+
157
+ all_metrics: list[str] = []
158
+ seen: set[str] = set()
159
+ for strat_scores in scores.values():
160
+ for k in strat_scores:
161
+ if k not in seen:
162
+ all_metrics.append(k)
163
+ seen.add(k)
164
+
165
+ with open(path, "w", newline="") as f:
166
+ writer = csv.writer(f)
167
+ writer.writerow(["metric"] + strategies)
168
+ for metric in all_metrics:
169
+ row = [metric]
170
+ for s in strategies:
171
+ val = scores[s].get(metric)
172
+ row.append(f"{val:.4f}" if val is not None else "")
173
+ writer.writerow(row)
@@ -0,0 +1,80 @@
1
+ """CLI, TUI, and terminal interaction helpers for ellements."""
2
+
3
+ from .adapters import (
4
+ guideline_provider_from_library,
5
+ persona_provider_from_library,
6
+ )
7
+ from .agent_tui import (
8
+ BUILTIN_COMMANDS,
9
+ AgentRunner,
10
+ AgentTUI,
11
+ FileSaveHandler,
12
+ GuidelineProvider,
13
+ Mode,
14
+ PersonaProvider,
15
+ RunResult,
16
+ SaveHandler,
17
+ SlashCommand,
18
+ TuiConfig,
19
+ TuiControls,
20
+ run_agent_tui,
21
+ )
22
+ from .components import (
23
+ ISSUE_STYLES,
24
+ ROLE_STYLES,
25
+ app_header,
26
+ chat_input_prompt,
27
+ chat_message,
28
+ error,
29
+ issue_line,
30
+ issues_block,
31
+ key_value_table,
32
+ result_json,
33
+ result_markdown,
34
+ section_rule,
35
+ stats_footer,
36
+ step_log,
37
+ success,
38
+ tool_use_badge,
39
+ warning,
40
+ welcome_banner,
41
+ )
42
+ from .printer import CliPrinter, EventRenderer
43
+
44
+ __all__ = [
45
+ "AgentRunner",
46
+ "AgentTUI",
47
+ "BUILTIN_COMMANDS",
48
+ "CliPrinter",
49
+ "EventRenderer",
50
+ "FileSaveHandler",
51
+ "GuidelineProvider",
52
+ "ISSUE_STYLES",
53
+ "Mode",
54
+ "PersonaProvider",
55
+ "ROLE_STYLES",
56
+ "RunResult",
57
+ "SaveHandler",
58
+ "SlashCommand",
59
+ "TuiConfig",
60
+ "TuiControls",
61
+ "app_header",
62
+ "chat_input_prompt",
63
+ "chat_message",
64
+ "error",
65
+ "guideline_provider_from_library",
66
+ "issue_line",
67
+ "issues_block",
68
+ "key_value_table",
69
+ "persona_provider_from_library",
70
+ "result_json",
71
+ "result_markdown",
72
+ "run_agent_tui",
73
+ "section_rule",
74
+ "stats_footer",
75
+ "step_log",
76
+ "success",
77
+ "tool_use_badge",
78
+ "warning",
79
+ "welcome_banner",
80
+ ]
@@ -0,0 +1,73 @@
1
+ """Adapters between ellements core types and TUI provider Protocols.
2
+
3
+ The :mod:`ellements.cli.agent_tui` module defines ``PersonaProvider`` /
4
+ ``GuidelineProvider`` Protocols so the TUI never imports an
5
+ ``AgentController`` directly. This module supplies the canonical
6
+ adapters that bridge a :class:`~ellements.core.PersonaLibrary` /
7
+ :class:`~ellements.core.GuidelineLibrary` + controller pair to those
8
+ Protocols.
9
+
10
+ Apps that don't use ``AgentController`` can write their own adapters
11
+ and pass them to :class:`~ellements.cli.agent_tui.TuiConfig` directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from .agent_tui import GuidelineProvider, PersonaProvider
19
+
20
+
21
+ class _LibraryPersonaProvider:
22
+ """Bridge a ``PersonaLibrary`` + controller to ``PersonaProvider``."""
23
+
24
+ def __init__(self, library: Any, controller: Any) -> None:
25
+ self._library = library
26
+ self._controller = controller
27
+
28
+ def list_ids(self) -> list[str]:
29
+ return list(self._library.list_ids())
30
+
31
+ def current_id(self) -> str | None:
32
+ return getattr(self._controller, "current_persona_id", None)
33
+
34
+ def select(self, persona_id: str) -> None:
35
+ self._controller.set_persona_by_id(persona_id)
36
+
37
+
38
+ class _LibraryGuidelineProvider:
39
+ """Bridge a ``GuidelineLibrary`` + controller to ``GuidelineProvider``."""
40
+
41
+ def __init__(self, library: Any, controller: Any) -> None:
42
+ self._library = library
43
+ self._controller = controller
44
+
45
+ def list_ids(self) -> list[str]:
46
+ return list(self._library.list_ids())
47
+
48
+ def current_id(self) -> str | None:
49
+ return getattr(self._controller, "current_guideline_id", None)
50
+
51
+ def select(self, guideline_id: str) -> None:
52
+ self._controller.set_guideline_by_id(guideline_id)
53
+
54
+ def clear(self) -> None:
55
+ self._controller.clear_guideline()
56
+
57
+
58
+ def persona_provider_from_library(library: Any, controller: Any) -> PersonaProvider:
59
+ """Adapt a :class:`PersonaLibrary` + controller pair to the TUI."""
60
+ return _LibraryPersonaProvider(library, controller)
61
+
62
+
63
+ def guideline_provider_from_library(
64
+ library: Any, controller: Any
65
+ ) -> GuidelineProvider:
66
+ """Adapt a :class:`GuidelineLibrary` + controller pair to the TUI."""
67
+ return _LibraryGuidelineProvider(library, controller)
68
+
69
+
70
+ __all__ = [
71
+ "guideline_provider_from_library",
72
+ "persona_provider_from_library",
73
+ ]