slmeval-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. slmeval/__init__.py +71 -0
  2. slmeval/__main__.py +14 -0
  3. slmeval/adapters/__init__.py +24 -0
  4. slmeval/adapters/instrument.py +135 -0
  5. slmeval/adapters/langgraph.py +237 -0
  6. slmeval/cli/__init__.py +15 -0
  7. slmeval/cli/commands/__init__.py +35 -0
  8. slmeval/cli/commands/baseline_cmd.py +201 -0
  9. slmeval/cli/commands/cache_cmd.py +249 -0
  10. slmeval/cli/commands/dataset_cmd.py +52 -0
  11. slmeval/cli/commands/doctor_cmd.py +68 -0
  12. slmeval/cli/commands/gate_cmd.py +602 -0
  13. slmeval/cli/commands/init_cmd.py +144 -0
  14. slmeval/cli/commands/judge_cmd.py +284 -0
  15. slmeval/cli/commands/record_cmd.py +154 -0
  16. slmeval/cli/commands/report_cmd.py +98 -0
  17. slmeval/cli/commands/run_cmd.py +247 -0
  18. slmeval/cli/commands/show_cmd.py +482 -0
  19. slmeval/cli/commands/stubs.py +57 -0
  20. slmeval/cli/commands/validate_cmd.py +296 -0
  21. slmeval/cli/loading.py +149 -0
  22. slmeval/cli/main.py +91 -0
  23. slmeval/cli/render.py +251 -0
  24. slmeval/cli/wrap.py +31 -0
  25. slmeval/config/__init__.py +47 -0
  26. slmeval/config/loader.py +363 -0
  27. slmeval/config/locate.py +97 -0
  28. slmeval/config/models.py +430 -0
  29. slmeval/config/templates.py +246 -0
  30. slmeval/core/__init__.py +78 -0
  31. slmeval/core/adapter.py +253 -0
  32. slmeval/core/context.py +418 -0
  33. slmeval/core/outcome.py +260 -0
  34. slmeval/core/router.py +319 -0
  35. slmeval/core/runner.py +1209 -0
  36. slmeval/core/trace.py +188 -0
  37. slmeval/core/verdict.py +127 -0
  38. slmeval/dataset/__init__.py +8 -0
  39. slmeval/dataset/loader.py +130 -0
  40. slmeval/dataset/models.py +108 -0
  41. slmeval/doctor/__init__.py +7 -0
  42. slmeval/doctor/checks.py +380 -0
  43. slmeval/errors.py +76 -0
  44. slmeval/eval/__init__.py +64 -0
  45. slmeval/eval/assertions.py +356 -0
  46. slmeval/eval/calibration.py +364 -0
  47. slmeval/eval/cost.py +291 -0
  48. slmeval/eval/pipeline.py +273 -0
  49. slmeval/eval/stats.py +376 -0
  50. slmeval/eval/tier1.py +510 -0
  51. slmeval/eval/tier2.py +582 -0
  52. slmeval/eval/tier3.py +415 -0
  53. slmeval/exit_codes.py +28 -0
  54. slmeval/gate.py +722 -0
  55. slmeval/providers/__init__.py +69 -0
  56. slmeval/providers/base.py +426 -0
  57. slmeval/providers/fake.py +760 -0
  58. slmeval/providers/jsonx.py +239 -0
  59. slmeval/providers/ladder.py +339 -0
  60. slmeval/providers/openai_compat.py +392 -0
  61. slmeval/providers/registry.py +185 -0
  62. slmeval/providers/retry.py +81 -0
  63. slmeval/providers/schema.py +136 -0
  64. slmeval/providers/tokens.py +150 -0
  65. slmeval/py.typed +0 -0
  66. slmeval/report/__init__.py +115 -0
  67. slmeval/report/attribution.py +197 -0
  68. slmeval/report/baseline.py +353 -0
  69. slmeval/report/drilldown.py +1395 -0
  70. slmeval/report/export.py +450 -0
  71. slmeval/report/model.py +562 -0
  72. slmeval/report/progress.py +257 -0
  73. slmeval/report/scorecard.py +367 -0
  74. slmeval/sandbox/__init__.py +145 -0
  75. slmeval/sandbox/diagnosis.py +690 -0
  76. slmeval/sandbox/envelope.py +486 -0
  77. slmeval/sandbox/faults.py +794 -0
  78. slmeval/sandbox/keys.py +458 -0
  79. slmeval/sandbox/normalizers.py +333 -0
  80. slmeval/sandbox/store.py +525 -0
  81. slmeval/sandbox/tool.py +685 -0
  82. slmeval/storage/__init__.py +73 -0
  83. slmeval/storage/blobs.py +308 -0
  84. slmeval/storage/codec.py +93 -0
  85. slmeval/storage/db.py +1334 -0
  86. slmeval/storage/ingest.py +346 -0
  87. slmeval/storage/migrations.py +358 -0
  88. slmeval/storage/models.py +554 -0
  89. slmeval/storage/provenance.py +201 -0
  90. slmeval_cli-0.1.0.dist-info/METADATA +484 -0
  91. slmeval_cli-0.1.0.dist-info/RECORD +94 -0
  92. slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
  93. slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
slmeval/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """slmeval — benchmark agent workflows across small language models.
2
+
3
+ The public API is intentionally tiny. Integration is two touch points:
4
+
5
+ from slmeval import chat_model, node, sandbox_tool
6
+
7
+ async def run_case(case_input: dict) -> dict:
8
+ with node("intent_router"): # names the row in the scorecard
9
+ llm = chat_model() # routed per candidate, per node
10
+ decision = await llm.complete(msgs, schema=Routing)
11
+
12
+ `node` is a no-op outside a benchmark run, and `chat_model` falls through to
13
+ whichever model you name for production, so this is safe to leave in shipped
14
+ code:
15
+
16
+ llm = chat_model(provider="openai", model="gpt-4o-mini")
17
+
18
+ The second touch point is `@sandbox_tool`, which records a tool call once and
19
+ then replays it for every candidate, so a 90-execution matrix never touches your
20
+ real APIs:
21
+
22
+ @sandbox_tool(match_on=["order_id"], ignore_args=["request_id"])
23
+ async def fetch_order(order_id: str, request_id: str) -> dict:
24
+ ...
25
+
26
+ It is a no-op outside a benchmark too, so in production your tools do exactly
27
+ what they always did.
28
+
29
+ With LangGraph there is a third, which replaces the `node()` blocks entirely:
30
+
31
+ builder = slmeval.instrument_langgraph(StateGraph(State))
32
+ builder.add_node("intent_router", route)
33
+
34
+ Every node then names itself in the scorecard, and the graph still runs
35
+ unchanged outside a benchmark.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ from slmeval.adapters import instrument_langgraph, instrument_node
43
+ from slmeval.core.context import RunContext, node
44
+ from slmeval.core.router import ChatModel, chat_model
45
+ from slmeval.providers.base import (
46
+ Completion,
47
+ Msg,
48
+ ProviderError,
49
+ StructuredOutputError,
50
+ ToolCall,
51
+ ToolSpec,
52
+ )
53
+ from slmeval.sandbox.tool import CassetteMiss, sandbox_tool
54
+
55
+ __all__ = [
56
+ "CassetteMiss",
57
+ "ChatModel",
58
+ "Completion",
59
+ "Msg",
60
+ "ProviderError",
61
+ "RunContext",
62
+ "StructuredOutputError",
63
+ "ToolCall",
64
+ "ToolSpec",
65
+ "__version__",
66
+ "chat_model",
67
+ "instrument_langgraph",
68
+ "instrument_node",
69
+ "node",
70
+ "sandbox_tool",
71
+ ]
slmeval/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """``python -m slmeval``, for when the console script is not on PATH.
2
+
3
+ A fresh virtualenv on Windows often is not activated in the shell the user
4
+ happens to be in, and "slmeval: command not found" five seconds after a
5
+ successful install is a bad first five minutes. This is the same entry point
6
+ the ``slmeval`` command uses.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from slmeval.cli.main import main
12
+
13
+ if __name__ == "__main__":
14
+ main()
@@ -0,0 +1,24 @@
1
+ """Framework adapters: the same benchmark, without the user marking anything.
2
+
3
+ There are two, and the gradient between them is deliberate.
4
+
5
+ :mod:`slmeval.adapters.instrument` is the generic machinery - a wrapper that
6
+ turns one callable into a timed, attributed, traced node. It knows nothing
7
+ about any framework and is what the others are built from.
8
+
9
+ :mod:`slmeval.adapters.langgraph` wraps every node of a LangGraph builder, so
10
+ a user with a graph writes one line and marks nothing by hand.
11
+
12
+ The adapter is never where the measurement happens. Model routing comes from
13
+ the ambient context, tool interception from ``@sandbox_tool``, and both work
14
+ with no adapter at all - so an adapter can only ever *add* attribution. That is
15
+ what keeps a framework integration from becoming a second implementation of the
16
+ harness, which is the way integrations usually rot.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from slmeval.adapters.instrument import instrument_node, state_keys
22
+ from slmeval.adapters.langgraph import instrument_langgraph
23
+
24
+ __all__ = ["instrument_langgraph", "instrument_node", "state_keys"]
@@ -0,0 +1,135 @@
1
+ """Turning one callable into an attributed node.
2
+
3
+ This is the piece every framework adapter is built from, and it is where the
4
+ blueprint's node wrapper had three bugs worth fixing by name, because each one
5
+ corrupts a number the scorecard prints.
6
+
7
+ **Exit through ``reset(token)``.** The blueprint restored the contextvar with
8
+ ``set(token.old_value)``, which is not the contextvars API and goes wrong the
9
+ first time nodes nest - which is the first thing any real graph does. Entry and
10
+ exit both go through :func:`slmeval.core.context.entering_node` here, so there
11
+ is exactly one place that could get it wrong.
12
+
13
+ **Record in ``finally``, not per-``except``.** The blueprint's sync wrapper
14
+ caught the schema error and lost every other exception: a node that raised
15
+ ``KeyError`` left no trace at all, so the denominator of every per-node rate
16
+ silently shrank. One ``finally``, one record, whatever happened - including
17
+ cancellation, which is how a benchmark's own ``timeout_s`` arrives.
18
+
19
+ **A crash is not a schema failure.** The blueprint recorded
20
+ ``schema_valid=True`` on the generic-exception path, which quietly mixed
21
+ unrelated crashes into the one metric this tool exists to report. Schema
22
+ validity is tri-state, it lives on the model call
23
+ (:class:`~slmeval.core.trace.SchemaStatus`), and nothing here touches it: a
24
+ node that raised records an *error*, which is a different column.
25
+
26
+ Outside a benchmark the wrapper is a thin pass-through - it enters a node whose
27
+ trajectory nobody is collecting and calls the original. That is what makes it
28
+ safe to leave an instrumented graph in shipped code.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import inspect
34
+ import time
35
+ from collections.abc import Callable, Mapping
36
+ from functools import wraps
37
+ from typing import Any
38
+
39
+ from slmeval.core.context import entering_node
40
+ from slmeval.core.trace import NodeVisit
41
+
42
+ ERROR_CHARS = 300
43
+ """How much of an exception message is kept.
44
+
45
+ Enough to recognise, not enough to be a liability: one of these is stored per
46
+ node per execution, and a 4 kB traceback times ninety executions is a trace
47
+ store that costs more than the thing it measures. The *type* is what
48
+ aggregates anyway.
49
+ """
50
+
51
+
52
+ def state_keys(value: Any) -> tuple[str, ...]:
53
+ """The keys of a state object, sorted, or ``()`` if it has none.
54
+
55
+ Keys and never values. A trace store must not quietly become a copy of a
56
+ customer's conversation state, and the shape is what carries the finding
57
+ anyway: a node that was supposed to write ``refund_eligible`` and returned
58
+ an empty dict is visible from the keys alone.
59
+ """
60
+ if isinstance(value, Mapping):
61
+ return tuple(sorted(str(k) for k in value))
62
+ # Off the class, not the instance: Pydantic deprecated the instance
63
+ # attribute, and a benchmark that printed a DeprecationWarning per node per
64
+ # execution would teach people to filter our warnings.
65
+ owner = value if isinstance(value, type) else type(value)
66
+ fields = getattr(owner, "model_fields", None) or getattr(owner, "__dataclass_fields__", None)
67
+ if isinstance(fields, Mapping):
68
+ return tuple(sorted(str(k) for k in fields))
69
+ return ()
70
+
71
+
72
+ def _record(
73
+ visit: NodeVisit | None, started: float, *, error: BaseException | None
74
+ ) -> None:
75
+ if visit is None:
76
+ return
77
+ visit.latency_ms = (time.perf_counter() - started) * 1000.0
78
+ if error is not None:
79
+ visit.error_type = type(error).__name__
80
+ visit.error = str(error)[:ERROR_CHARS] or None
81
+
82
+
83
+ def instrument_node(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
84
+ """Wrap ``func`` so that calling it enters the node ``name``.
85
+
86
+ Async-ness is preserved: a framework that awaits its nodes has to get a
87
+ coroutine function back, and one that calls them synchronously must not be
88
+ handed a coroutine it never awaits.
89
+ """
90
+ if inspect.iscoroutinefunction(func):
91
+
92
+ @wraps(func)
93
+ async def async_node(*args: Any, **kwargs: Any) -> Any:
94
+ with entering_node(name) as visit:
95
+ if visit is not None and args:
96
+ visit.input_keys = state_keys(args[0])
97
+ started = time.perf_counter()
98
+ failure: BaseException | None = None
99
+ try:
100
+ result = await func(*args, **kwargs)
101
+ except BaseException as exc:
102
+ failure = exc
103
+ raise
104
+ else:
105
+ if visit is not None:
106
+ visit.output_keys = state_keys(result)
107
+ return result
108
+ finally:
109
+ _record(visit, started, error=failure)
110
+
111
+ return async_node
112
+
113
+ @wraps(func)
114
+ def sync_node(*args: Any, **kwargs: Any) -> Any:
115
+ with entering_node(name) as visit:
116
+ if visit is not None and args:
117
+ visit.input_keys = state_keys(args[0])
118
+ started = time.perf_counter()
119
+ failure: BaseException | None = None
120
+ try:
121
+ result = func(*args, **kwargs)
122
+ except BaseException as exc:
123
+ failure = exc
124
+ raise
125
+ else:
126
+ if visit is not None:
127
+ visit.output_keys = state_keys(result)
128
+ return result
129
+ finally:
130
+ _record(visit, started, error=failure)
131
+
132
+ return sync_node
133
+
134
+
135
+ __all__ = ["ERROR_CHARS", "instrument_node", "state_keys"]
@@ -0,0 +1,237 @@
1
+ """The LangGraph adapter: one line, and every node is attributed.
2
+
3
+ import slmeval
4
+ from langgraph.graph import StateGraph
5
+
6
+ builder = slmeval.instrument_langgraph(StateGraph(State))
7
+ builder.add_node("intent_router", route)
8
+ builder.add_node("order_lookup", lookup)
9
+ graph = builder.compile()
10
+
11
+ Nothing else changes. The user writes no ``with node(...)`` blocks and the
12
+ scorecard still has a row per node - which is the whole value of a framework
13
+ adapter, because per-node attribution is the part nobody will hand-annotate
14
+ across a thirty-node graph.
15
+
16
+ **Instrument the builder, then add the nodes.** That ordering is the supported
17
+ path and the reason this adapter is boring: it replaces the builder's own
18
+ ``add_node``, so each node's callable is wrapped *before* LangGraph ever sees
19
+ it, and from that point on the library's internals are none of our business.
20
+ Calling it after the nodes are added also works - the registered callables are
21
+ wrapped in place - but that path has to know how a given LangGraph version
22
+ stores a node, and it says so loudly rather than wrapping nothing when it does
23
+ not recognise one. An adapter that quietly attributed every call to ``None``
24
+ would print a flat scorecard, which reads as a finding rather than as a bug.
25
+
26
+ LangGraph itself is never imported here. It is an optional extra
27
+ (``pip install "slmeval-cli[langgraph]"``), the core must install in seconds
28
+ without a model framework, and the only thing this file needs to know is that
29
+ a builder has named nodes holding callables.
30
+
31
+ The adapter adds attribution and nothing else. Model routing still comes from
32
+ the ambient context and tool interception from ``@sandbox_tool``, both of which
33
+ work with no adapter at all - so an instrumented graph and a hand-marked one
34
+ produce the same trajectory. ``examples/support_agent`` is benchmarked both
35
+ ways to keep that true.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from collections.abc import Callable
41
+ from typing import Any
42
+
43
+ from slmeval.adapters.instrument import instrument_node
44
+ from slmeval.errors import ConfigError
45
+
46
+ MARK = "_slmeval_instrumented"
47
+ """Set on anything this module has wrapped, so a second call is a no-op.
48
+
49
+ ``instrument_langgraph(builder)`` twice - in a test fixture and again in the
50
+ graph's own factory, say - would otherwise enter every node twice, and Tier 2
51
+ would read the doubled trajectory as the agent looping.
52
+ """
53
+
54
+ #: Where a node's callable lives, in the versions this has been built against.
55
+ #: ``func``/``afunc`` are LangGraph's own ``RunnableCallable``; ``invoke``/
56
+ #: ``ainvoke`` are the generic Runnable surface, wrapped only as a fallback.
57
+ CALLABLE_ATTRS = ("func", "afunc", "invoke", "ainvoke")
58
+
59
+ #: Where a builder keeps the runnable for a node.
60
+ NODE_ATTRS = ("runnable", "action", "bound")
61
+
62
+
63
+ def instrument_langgraph(builder: Any, *, prefix: str = "") -> Any:
64
+ """Wrap every node of a LangGraph builder so each one names itself.
65
+
66
+ Returns the same builder, so it reads as a pipeline step either way:
67
+
68
+ builder = slmeval.instrument_langgraph(StateGraph(State))
69
+ ...
70
+ graph = builder.compile()
71
+
72
+ ``prefix`` namespaces the node names, for an agent that composes two graphs
73
+ with a ``plan`` node in each. Without it the two share one scorecard row
74
+ and "which plan node is failing" has no answer.
75
+ """
76
+ if not hasattr(builder, "add_node"):
77
+ raise ConfigError(
78
+ f"instrument_langgraph() needs a graph builder, not {type(builder).__name__}",
79
+ hint=(
80
+ "Pass the StateGraph, not the compiled graph: a compiled "
81
+ "graph's nodes are already bound, and wrapping them would not "
82
+ "change what runs."
83
+ ),
84
+ )
85
+
86
+ _patch_add_node(builder, prefix)
87
+ existing = _existing_nodes(builder)
88
+ for name, holder in existing:
89
+ _wrap_node(_named(name, prefix), holder)
90
+
91
+ # The failure worth reporting is "nothing here was even a callable", not
92
+ # "nothing was wrapped": a second call finds every node already wrapped,
93
+ # which is success.
94
+ if existing and not any(_reachable(holder) for _, holder in existing):
95
+ raise ConfigError(
96
+ f"instrument_langgraph() could not reach the callable behind "
97
+ f"{len(existing)} already-registered node(s)",
98
+ hint=(
99
+ "Instrument the builder *before* adding nodes - "
100
+ "`builder = slmeval.instrument_langgraph(StateGraph(State))` - "
101
+ "which needs to know nothing about how your LangGraph version "
102
+ 'stores a node. Or use adapter = "generic": call '
103
+ "slmeval.chat_model() in your node functions and wrap each in "
104
+ '`with node("name")`, for the same attribution by hand.'
105
+ ),
106
+ )
107
+ return builder
108
+
109
+
110
+ # --------------------------------------------------------------------------- #
111
+ # Before the nodes are added: replace add_node
112
+ # --------------------------------------------------------------------------- #
113
+
114
+
115
+ def _patch_add_node(builder: Any, prefix: str) -> None:
116
+ """Wrap each node's callable on its way in.
117
+
118
+ The supported path, and the one that needs no knowledge of LangGraph's
119
+ internals: ``add_node(name, action)`` is the public API, and the action is
120
+ still the user's own function when it arrives here.
121
+ """
122
+ original = builder.add_node
123
+ if getattr(original, MARK, False):
124
+ return
125
+
126
+ def add_node(node: Any = None, action: Any = None, **kwargs: Any) -> Any:
127
+ name, func, rest = _split_add_node_args(node, action)
128
+ if func is not None:
129
+ name = name or getattr(func, "__name__", None)
130
+ if name:
131
+ func = _instrument(_named(str(name), prefix), func)
132
+ if rest is None:
133
+ return original(name, func, **kwargs) if name else original(func, **kwargs)
134
+ return original(*rest, **kwargs)
135
+
136
+ add_node._slmeval_instrumented = True # type: ignore[attr-defined]
137
+ builder.add_node = add_node
138
+
139
+
140
+ def _split_add_node_args(
141
+ node: Any, action: Any
142
+ ) -> tuple[str | None, Callable[..., Any] | None, tuple[Any, ...] | None]:
143
+ """Make sense of ``add_node``'s two accepted shapes.
144
+
145
+ ``add_node("name", func)`` and ``add_node(func)`` - where the node takes
146
+ the function's own name - are both public, and a wrapper that handled only
147
+ the first would break every graph written in the second style. Anything
148
+ else is passed through untouched rather than guessed at.
149
+ """
150
+ if action is None and callable(node):
151
+ return None, node, None
152
+ if isinstance(node, str) and callable(action):
153
+ return node, action, None
154
+ return None, None, (node, action)
155
+
156
+
157
+ # --------------------------------------------------------------------------- #
158
+ # After the nodes are added: wrap what is registered
159
+ # --------------------------------------------------------------------------- #
160
+
161
+
162
+ def _existing_nodes(builder: Any) -> list[tuple[str, Any]]:
163
+ nodes = getattr(builder, "nodes", None)
164
+ if not isinstance(nodes, dict):
165
+ return []
166
+ return [(str(name), holder) for name, holder in nodes.items()]
167
+
168
+
169
+ def _wrap_node(name: str, holder: Any) -> bool:
170
+ """Instrument one registered node in place. True if anything was wrapped.
171
+
172
+ The holder is a node spec whose ``runnable`` holds the callable, or - in
173
+ the simplest builders, and in the stub this is tested against - the
174
+ callable itself. The runnable is wrapped *in place* rather than replaced,
175
+ because LangGraph's executor calls methods on it that a bare function does
176
+ not have.
177
+ """
178
+ for attribute in NODE_ATTRS:
179
+ runnable = getattr(holder, attribute, None)
180
+ if runnable is not None and _wrap_in_place(name, runnable):
181
+ return True
182
+ if callable(holder):
183
+ return _wrap_in_place(name, holder)
184
+ return False
185
+
186
+
187
+ def _wrap_in_place(name: str, runnable: Any) -> bool:
188
+ if getattr(runnable, MARK, False):
189
+ return False
190
+ wrapped = False
191
+ for attribute in CALLABLE_ATTRS:
192
+ current = getattr(runnable, attribute, None)
193
+ if current is None or not callable(current):
194
+ continue
195
+ if _set(runnable, attribute, _instrument(name, current)):
196
+ wrapped = True
197
+ if wrapped:
198
+ _set(runnable, MARK, True)
199
+ return wrapped
200
+
201
+
202
+ def _instrument(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
203
+ if getattr(func, MARK, False):
204
+ return func
205
+ wrapped = instrument_node(name, func)
206
+ _set(wrapped, MARK, True)
207
+ return wrapped
208
+
209
+
210
+ def _set(obj: Any, attribute: str, value: Any) -> bool:
211
+ """Assign, through whatever the object allows. False if it allows nothing."""
212
+ try:
213
+ setattr(obj, attribute, value)
214
+ except (AttributeError, TypeError, ValueError):
215
+ try:
216
+ object.__setattr__(obj, attribute, value)
217
+ except (AttributeError, TypeError, ValueError):
218
+ return False
219
+ return True
220
+
221
+
222
+ def _reachable(holder: Any) -> bool:
223
+ """Whether this node holds a callable the adapter could reach at all."""
224
+ for attribute in NODE_ATTRS:
225
+ runnable = getattr(holder, attribute, None)
226
+ if runnable is None:
227
+ continue
228
+ if any(callable(getattr(runnable, a, None)) for a in CALLABLE_ATTRS):
229
+ return True
230
+ return callable(holder)
231
+
232
+
233
+ def _named(name: str, prefix: str) -> str:
234
+ return f"{prefix}{name}" if prefix else name
235
+
236
+
237
+ __all__ = ["CALLABLE_ATTRS", "MARK", "NODE_ATTRS", "instrument_langgraph"]
@@ -0,0 +1,15 @@
1
+ """Terminal interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["app", "main"]
6
+
7
+
8
+ def __getattr__(name: str) -> object:
9
+ # Lazy so `import slmeval.cli` stays cheap and import cycles cannot form
10
+ # between main.py and the command modules.
11
+ if name in __all__:
12
+ from slmeval.cli import main as _main
13
+
14
+ return getattr(_main, name)
15
+ raise AttributeError(name)
@@ -0,0 +1,35 @@
1
+ """One module per command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from slmeval.cli.commands import (
6
+ baseline_cmd,
7
+ cache_cmd,
8
+ dataset_cmd,
9
+ doctor_cmd,
10
+ gate_cmd,
11
+ init_cmd,
12
+ judge_cmd,
13
+ record_cmd,
14
+ report_cmd,
15
+ run_cmd,
16
+ show_cmd,
17
+ stubs,
18
+ validate_cmd,
19
+ )
20
+
21
+ __all__ = [
22
+ "baseline_cmd",
23
+ "cache_cmd",
24
+ "dataset_cmd",
25
+ "doctor_cmd",
26
+ "gate_cmd",
27
+ "init_cmd",
28
+ "judge_cmd",
29
+ "record_cmd",
30
+ "report_cmd",
31
+ "run_cmd",
32
+ "show_cmd",
33
+ "stubs",
34
+ "validate_cmd",
35
+ ]