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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- ellements-0.2.0.dist-info/top_level.txt +1 -0
ellements/fslm/dsl.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Fluent Python DSL for building `MachineSpec` objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .definition import MachineDefinition, RuntimeBindings
|
|
9
|
+
from .det import (
|
|
10
|
+
BoundAction,
|
|
11
|
+
BoundEffect,
|
|
12
|
+
BoundGuard,
|
|
13
|
+
BoundInvariant,
|
|
14
|
+
BoundOutput,
|
|
15
|
+
Increment,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
ActionSpec,
|
|
19
|
+
AffordanceSpec,
|
|
20
|
+
EventPatternSpec,
|
|
21
|
+
ExecutionPolicy,
|
|
22
|
+
GuardSpec,
|
|
23
|
+
InvariantSpec,
|
|
24
|
+
MachineSpec,
|
|
25
|
+
OutputSpec,
|
|
26
|
+
StateSpec,
|
|
27
|
+
ToolRef,
|
|
28
|
+
TransitionKind,
|
|
29
|
+
TransitionSpec,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MachineBuilder:
|
|
34
|
+
"""Low-boilerplate builder for `MachineSpec`."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
name: str,
|
|
39
|
+
*,
|
|
40
|
+
initial: str,
|
|
41
|
+
description: str = "",
|
|
42
|
+
version: str = "0.1",
|
|
43
|
+
) -> None:
|
|
44
|
+
self.name = name
|
|
45
|
+
self.initial = initial
|
|
46
|
+
self.description = description.strip()
|
|
47
|
+
self.version = version
|
|
48
|
+
self._states: dict[str, StateSpec] = {}
|
|
49
|
+
self._transitions: dict[str, TransitionSpec] = {}
|
|
50
|
+
self._tools: dict[str, ToolRef] = {}
|
|
51
|
+
self._outputs: dict[str, OutputSpec] = {}
|
|
52
|
+
self._policy = ExecutionPolicy()
|
|
53
|
+
self._bindings = RuntimeBindings()
|
|
54
|
+
self._transition_counter = 0
|
|
55
|
+
|
|
56
|
+
def policy(self, **kwargs: Any) -> MachineBuilder:
|
|
57
|
+
"""Update machine-wide execution policy."""
|
|
58
|
+
data = self._policy.model_dump()
|
|
59
|
+
data.update(kwargs)
|
|
60
|
+
self._policy = ExecutionPolicy.model_validate(data)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def use_tools(self, *tools: Any) -> MachineBuilder:
|
|
64
|
+
"""Register ellements tool specs or light tool references."""
|
|
65
|
+
for tool in tools:
|
|
66
|
+
name = getattr(tool, "name", None)
|
|
67
|
+
if name is None:
|
|
68
|
+
raise TypeError("tools must expose a `name` attribute")
|
|
69
|
+
description = str(getattr(tool, "description", ""))
|
|
70
|
+
schema = getattr(tool, "params_json_schema", None) or {}
|
|
71
|
+
self._tools[str(name)] = ToolRef(
|
|
72
|
+
name=str(name),
|
|
73
|
+
description=description,
|
|
74
|
+
params_json_schema=dict(schema),
|
|
75
|
+
)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def state(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
*,
|
|
82
|
+
objective: str = "",
|
|
83
|
+
description: str = "",
|
|
84
|
+
tools: list[str] | None = None,
|
|
85
|
+
emits: list[str | OutputSpec] | None = None,
|
|
86
|
+
terminal: bool = False,
|
|
87
|
+
) -> StateBuilder:
|
|
88
|
+
"""Open a state-local affordance block."""
|
|
89
|
+
state = StateSpec(
|
|
90
|
+
name=name,
|
|
91
|
+
objective=objective.strip(),
|
|
92
|
+
description=description.strip(),
|
|
93
|
+
tools=tools or [],
|
|
94
|
+
emits=[_output_spec(item) for item in emits or []],
|
|
95
|
+
terminal=terminal,
|
|
96
|
+
)
|
|
97
|
+
self._states[name] = state
|
|
98
|
+
return StateBuilder(self, state)
|
|
99
|
+
|
|
100
|
+
def build(self) -> MachineDefinition:
|
|
101
|
+
"""Materialize the executable machine definition."""
|
|
102
|
+
spec = MachineSpec(
|
|
103
|
+
name=self.name,
|
|
104
|
+
description=self.description,
|
|
105
|
+
version=self.version,
|
|
106
|
+
initial=self.initial,
|
|
107
|
+
states=self._states,
|
|
108
|
+
transitions=self._transitions,
|
|
109
|
+
tools=self._tools,
|
|
110
|
+
outputs=self._outputs,
|
|
111
|
+
policy=self._policy,
|
|
112
|
+
)
|
|
113
|
+
return MachineDefinition(spec=spec, bindings=self._bindings)
|
|
114
|
+
|
|
115
|
+
def build_spec(self) -> MachineSpec:
|
|
116
|
+
"""Materialize only the serializable machine specification."""
|
|
117
|
+
return self.build().spec
|
|
118
|
+
|
|
119
|
+
def _register_transition(self, transition: TransitionSpec) -> None:
|
|
120
|
+
if transition.name in self._transitions:
|
|
121
|
+
raise ValueError(f"transition {transition.name!r} is already declared")
|
|
122
|
+
self._transitions[transition.name] = transition
|
|
123
|
+
state = self._states[transition.source]
|
|
124
|
+
bucket = (
|
|
125
|
+
state.recovery_transitions
|
|
126
|
+
if transition.kind == "recovery"
|
|
127
|
+
else state.transitions
|
|
128
|
+
)
|
|
129
|
+
bucket.append(transition.name)
|
|
130
|
+
|
|
131
|
+
def _next_transition_name(
|
|
132
|
+
self,
|
|
133
|
+
source: str,
|
|
134
|
+
event_type: str,
|
|
135
|
+
target: str | None = None,
|
|
136
|
+
) -> str:
|
|
137
|
+
if target is not None:
|
|
138
|
+
base = f"{source}_{event_type}_to_{target}"
|
|
139
|
+
if base not in self._transitions:
|
|
140
|
+
return base
|
|
141
|
+
self._transition_counter += 1
|
|
142
|
+
return f"{source}_{event_type}_{self._transition_counter}"
|
|
143
|
+
|
|
144
|
+
def _register_guard_binding(self, value: str | GuardSpec | BoundGuard | Any) -> GuardSpec:
|
|
145
|
+
if isinstance(value, BoundGuard):
|
|
146
|
+
self._bindings.guards[value.spec.ref or value.spec.id] = value.fn
|
|
147
|
+
return value.spec
|
|
148
|
+
if isinstance(value, GuardSpec):
|
|
149
|
+
return value
|
|
150
|
+
if callable(value):
|
|
151
|
+
name = getattr(value, "__name__", "guard")
|
|
152
|
+
self._bindings.guards[name] = value
|
|
153
|
+
return GuardSpec(id=name, kind="deterministic", ref=name)
|
|
154
|
+
return GuardSpec(id=str(value), kind="deterministic")
|
|
155
|
+
|
|
156
|
+
def _register_invariant_binding(
|
|
157
|
+
self,
|
|
158
|
+
value: str | InvariantSpec | BoundInvariant | Any,
|
|
159
|
+
) -> InvariantSpec:
|
|
160
|
+
if isinstance(value, BoundInvariant):
|
|
161
|
+
self._bindings.invariants[value.spec.ref or value.spec.id] = value.fn
|
|
162
|
+
return value.spec
|
|
163
|
+
if isinstance(value, InvariantSpec):
|
|
164
|
+
return value
|
|
165
|
+
if callable(value):
|
|
166
|
+
name = getattr(value, "__name__", "invariant")
|
|
167
|
+
self._bindings.invariants[name] = value
|
|
168
|
+
return InvariantSpec(id=name, kind="deterministic", ref=name)
|
|
169
|
+
return InvariantSpec(id=str(value), kind="deterministic")
|
|
170
|
+
|
|
171
|
+
def _register_action_binding(
|
|
172
|
+
self,
|
|
173
|
+
value: str | ActionSpec | BoundAction,
|
|
174
|
+
*,
|
|
175
|
+
name: str | None,
|
|
176
|
+
instruction: str,
|
|
177
|
+
arguments: Mapping[str, Any] | None,
|
|
178
|
+
requires_approval: bool,
|
|
179
|
+
) -> ActionSpec:
|
|
180
|
+
if isinstance(value, BoundAction):
|
|
181
|
+
if value.fn is not None:
|
|
182
|
+
self._bindings.actions[value.spec.ref or value.spec.name or "action"] = value.fn
|
|
183
|
+
if value.argument_factory is not None:
|
|
184
|
+
ref = f"{value.spec.name or value.spec.tool}_arguments"
|
|
185
|
+
self._bindings.actions[ref] = value.argument_factory
|
|
186
|
+
value.spec.args["arguments_ref"] = ref
|
|
187
|
+
return value.spec
|
|
188
|
+
if isinstance(value, ActionSpec):
|
|
189
|
+
return value
|
|
190
|
+
return ActionSpec(
|
|
191
|
+
name=name or value,
|
|
192
|
+
kind="tool",
|
|
193
|
+
tool=str(value),
|
|
194
|
+
instruction=instruction.strip(),
|
|
195
|
+
arguments=dict(arguments or {}),
|
|
196
|
+
requires_approval=requires_approval,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _register_output_binding(
|
|
200
|
+
self,
|
|
201
|
+
value: str | OutputSpec | BoundOutput,
|
|
202
|
+
*,
|
|
203
|
+
description: str,
|
|
204
|
+
payload_schema: Mapping[str, Any] | None,
|
|
205
|
+
) -> OutputSpec:
|
|
206
|
+
if isinstance(value, BoundOutput):
|
|
207
|
+
self._bindings.outputs[value.spec.ref or value.spec.type] = value.fn
|
|
208
|
+
return value.spec
|
|
209
|
+
if isinstance(value, OutputSpec):
|
|
210
|
+
return value
|
|
211
|
+
return OutputSpec(
|
|
212
|
+
type=str(value),
|
|
213
|
+
description=description.strip(),
|
|
214
|
+
payload_schema=dict(payload_schema or {}),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class StateBuilder:
|
|
219
|
+
"""State-local builder; this is the machine's visible locus of control."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, machine: MachineBuilder, state: StateSpec) -> None:
|
|
222
|
+
self._machine = machine
|
|
223
|
+
self._state = state
|
|
224
|
+
|
|
225
|
+
def __enter__(self) -> StateBuilder:
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def __exit__(self, *_exc: object) -> None:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def affordance(self, name: str, description: str = "") -> StateBuilder:
|
|
232
|
+
"""Declare a state-local affordance."""
|
|
233
|
+
self._state.affordances.append(
|
|
234
|
+
AffordanceSpec(name=name, description=description.strip())
|
|
235
|
+
)
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def invariant(
|
|
239
|
+
self,
|
|
240
|
+
invariant: str | InvariantSpec | BoundInvariant | Any,
|
|
241
|
+
) -> StateBuilder:
|
|
242
|
+
"""Declare a state invariant."""
|
|
243
|
+
spec = self._machine._register_invariant_binding(invariant)
|
|
244
|
+
self._state.invariants.append(spec)
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def on(self, event_type: str, description: str = "") -> TransitionBuilder:
|
|
248
|
+
"""Begin a normal transition triggered by an event."""
|
|
249
|
+
return TransitionBuilder(
|
|
250
|
+
self._machine,
|
|
251
|
+
self._state,
|
|
252
|
+
event_type=event_type,
|
|
253
|
+
event_description=description.strip(),
|
|
254
|
+
kind="normal",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def recover(self, event_type: str, description: str = "") -> TransitionBuilder:
|
|
258
|
+
"""Begin an explicit recovery transition."""
|
|
259
|
+
return TransitionBuilder(
|
|
260
|
+
self._machine,
|
|
261
|
+
self._state,
|
|
262
|
+
event_type=event_type,
|
|
263
|
+
event_description=description.strip(),
|
|
264
|
+
kind="recovery",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TransitionBuilder:
|
|
269
|
+
"""Fluent builder for one or more transitions sharing an event trigger."""
|
|
270
|
+
|
|
271
|
+
def __init__(
|
|
272
|
+
self,
|
|
273
|
+
machine: MachineBuilder,
|
|
274
|
+
state: StateSpec,
|
|
275
|
+
*,
|
|
276
|
+
event_type: str,
|
|
277
|
+
event_description: str,
|
|
278
|
+
kind: TransitionKind,
|
|
279
|
+
) -> None:
|
|
280
|
+
self._machine = machine
|
|
281
|
+
self._state = state
|
|
282
|
+
self._event_type = event_type
|
|
283
|
+
self._event_description = event_description
|
|
284
|
+
self._kind = kind
|
|
285
|
+
self._registered: list[str] = []
|
|
286
|
+
|
|
287
|
+
def to(self, target: str, name: str | None = None) -> TransitionBuilder:
|
|
288
|
+
"""Declare a transition to another state."""
|
|
289
|
+
transition_name = name or self._machine._next_transition_name(
|
|
290
|
+
self._state.name,
|
|
291
|
+
self._event_type,
|
|
292
|
+
target,
|
|
293
|
+
)
|
|
294
|
+
self._register(target=target, name=transition_name)
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
def stay(self, name: str | None = None) -> TransitionBuilder:
|
|
298
|
+
"""Declare an explicit self-transition."""
|
|
299
|
+
return self.to(self._state.name, name=name)
|
|
300
|
+
|
|
301
|
+
def choose(
|
|
302
|
+
self,
|
|
303
|
+
*choices: tuple[float, str] | tuple[float, str, str],
|
|
304
|
+
) -> TransitionBuilder:
|
|
305
|
+
"""Declare weighted transitions for a probabilistic choice."""
|
|
306
|
+
group = f"{self._state.name}:{self._event_type}:{len(self._machine._transitions)}"
|
|
307
|
+
for choice in choices:
|
|
308
|
+
weight = float(choice[0])
|
|
309
|
+
target = str(choice[1])
|
|
310
|
+
name = (
|
|
311
|
+
str(choice[2])
|
|
312
|
+
if len(choice) == 3
|
|
313
|
+
else self._machine._next_transition_name(self._state.name, target, target)
|
|
314
|
+
)
|
|
315
|
+
self._register(target=target, name=name, weight=weight, random_group=group)
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
def when(self, *guards: str | GuardSpec | BoundGuard | Any) -> TransitionBuilder:
|
|
319
|
+
"""Attach guards to all transitions declared by this builder."""
|
|
320
|
+
for transition in self._current_transitions():
|
|
321
|
+
transition.guards.extend(
|
|
322
|
+
self._machine._register_guard_binding(guard) for guard in guards
|
|
323
|
+
)
|
|
324
|
+
return self
|
|
325
|
+
|
|
326
|
+
def do(
|
|
327
|
+
self,
|
|
328
|
+
action: str | ActionSpec | BoundAction,
|
|
329
|
+
*,
|
|
330
|
+
name: str | None = None,
|
|
331
|
+
instruction: str = "",
|
|
332
|
+
arguments: Mapping[str, Any] | None = None,
|
|
333
|
+
requires_approval: bool = False,
|
|
334
|
+
) -> TransitionBuilder:
|
|
335
|
+
"""Attach an action to all transitions declared by this builder."""
|
|
336
|
+
action_spec = self._machine._register_action_binding(
|
|
337
|
+
action,
|
|
338
|
+
name=name,
|
|
339
|
+
instruction=instruction,
|
|
340
|
+
arguments=arguments,
|
|
341
|
+
requires_approval=requires_approval,
|
|
342
|
+
)
|
|
343
|
+
for transition in self._current_transitions():
|
|
344
|
+
transition.actions.append(action_spec)
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
def call(
|
|
348
|
+
self,
|
|
349
|
+
fn: Any,
|
|
350
|
+
*,
|
|
351
|
+
name: str | None = None,
|
|
352
|
+
args: Mapping[str, Any] | None = None,
|
|
353
|
+
) -> TransitionBuilder:
|
|
354
|
+
"""Attach a side-effectful Python call action."""
|
|
355
|
+
if not callable(fn):
|
|
356
|
+
raise TypeError("call() expects a callable")
|
|
357
|
+
action_name = name or getattr(fn, "__name__", "python_call")
|
|
358
|
+
return self.do(BoundAction(
|
|
359
|
+
ActionSpec(
|
|
360
|
+
name=action_name,
|
|
361
|
+
kind="deterministic",
|
|
362
|
+
ref=action_name,
|
|
363
|
+
args=dict(args or {}),
|
|
364
|
+
),
|
|
365
|
+
fn=fn,
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
def emit(
|
|
369
|
+
self,
|
|
370
|
+
output: str | OutputSpec | BoundOutput,
|
|
371
|
+
description: str = "",
|
|
372
|
+
payload_schema: Mapping[str, Any] | None = None,
|
|
373
|
+
) -> TransitionBuilder:
|
|
374
|
+
"""Attach a typed output to all transitions declared by this builder."""
|
|
375
|
+
spec = self._machine._register_output_binding(
|
|
376
|
+
output,
|
|
377
|
+
description=description,
|
|
378
|
+
payload_schema=payload_schema,
|
|
379
|
+
)
|
|
380
|
+
for transition in self._current_transitions():
|
|
381
|
+
transition.emits.append(spec)
|
|
382
|
+
return self
|
|
383
|
+
|
|
384
|
+
def effects(self, **variables: Any) -> TransitionBuilder:
|
|
385
|
+
"""Attach variable updates to all transitions declared by this builder."""
|
|
386
|
+
normalized = {
|
|
387
|
+
key: {"op": "increment", "path": value.path, "by": value.by}
|
|
388
|
+
if isinstance(value, Increment)
|
|
389
|
+
else value
|
|
390
|
+
for key, value in variables.items()
|
|
391
|
+
}
|
|
392
|
+
for transition in self._current_transitions():
|
|
393
|
+
transition.effects.update(normalized)
|
|
394
|
+
return self
|
|
395
|
+
|
|
396
|
+
def effect(self, effect: BoundEffect) -> TransitionBuilder:
|
|
397
|
+
"""Attach a pure deterministic effect callable."""
|
|
398
|
+
self._machine._bindings.effects[effect.spec.ref] = effect.fn
|
|
399
|
+
for transition in self._current_transitions():
|
|
400
|
+
transition.effect_calls.append(effect.spec)
|
|
401
|
+
return self
|
|
402
|
+
|
|
403
|
+
def weight(self, value: float) -> TransitionBuilder:
|
|
404
|
+
"""Assign a probabilistic weight to all transitions declared here."""
|
|
405
|
+
for transition in self._current_transitions():
|
|
406
|
+
transition.weight = value
|
|
407
|
+
return self
|
|
408
|
+
|
|
409
|
+
def _register(
|
|
410
|
+
self,
|
|
411
|
+
*,
|
|
412
|
+
target: str,
|
|
413
|
+
name: str,
|
|
414
|
+
weight: float | None = None,
|
|
415
|
+
random_group: str | None = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
transition = TransitionSpec(
|
|
418
|
+
name=name,
|
|
419
|
+
source=self._state.name,
|
|
420
|
+
target=target,
|
|
421
|
+
event=EventPatternSpec(
|
|
422
|
+
type=self._event_type,
|
|
423
|
+
description=self._event_description,
|
|
424
|
+
),
|
|
425
|
+
kind=self._kind,
|
|
426
|
+
weight=weight,
|
|
427
|
+
random_group=random_group,
|
|
428
|
+
)
|
|
429
|
+
self._machine._register_transition(transition)
|
|
430
|
+
self._registered.append(name)
|
|
431
|
+
|
|
432
|
+
def _current_transitions(self) -> list[TransitionSpec]:
|
|
433
|
+
if not self._registered:
|
|
434
|
+
raise ValueError("declare `.to(...)`, `.stay(...)`, or `.choose(...)` first")
|
|
435
|
+
return [self._machine._transitions[name] for name in self._registered]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def machine(
|
|
439
|
+
name: str,
|
|
440
|
+
*,
|
|
441
|
+
initial: str,
|
|
442
|
+
description: str = "",
|
|
443
|
+
version: str = "0.1",
|
|
444
|
+
) -> MachineBuilder:
|
|
445
|
+
"""Create a fluent FSLM builder."""
|
|
446
|
+
return MachineBuilder(
|
|
447
|
+
name,
|
|
448
|
+
initial=initial,
|
|
449
|
+
description=description,
|
|
450
|
+
version=version,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _output_spec(value: str | OutputSpec) -> OutputSpec:
|
|
455
|
+
return value if isinstance(value, OutputSpec) else OutputSpec(type=value)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
__all__ = ["MachineBuilder", "StateBuilder", "TransitionBuilder", "machine"]
|
ellements/fslm/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Exception hierarchy for `ellements.fslm`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FSLMError(Exception):
|
|
7
|
+
"""Base error for FSLM runtime and specification failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpecValidationError(FSLMError, ValueError):
|
|
11
|
+
"""Raised when a machine specification is invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidTransitionError(FSLMError):
|
|
15
|
+
"""Raised when a transition cannot be applied."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvariantViolationError(FSLMError):
|
|
19
|
+
"""Raised when state invariants fail in strict contexts."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SafetyBlockedError(FSLMError):
|
|
23
|
+
"""Raised when an action is blocked by safety policy."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BudgetExhaustedError(FSLMError):
|
|
27
|
+
"""Raised when an FSLM run exceeds a declared budget."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BudgetExhaustedError",
|
|
32
|
+
"FSLMError",
|
|
33
|
+
"InvalidTransitionError",
|
|
34
|
+
"InvariantViolationError",
|
|
35
|
+
"SafetyBlockedError",
|
|
36
|
+
"SpecValidationError",
|
|
37
|
+
]
|
|
38
|
+
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Evaluator implementations for natural-language FSLM decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from ellements.core import LLMClient
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
DecisionResult,
|
|
12
|
+
FSLMEvent,
|
|
13
|
+
GuardSpec,
|
|
14
|
+
InvariantSpec,
|
|
15
|
+
MachineSnapshot,
|
|
16
|
+
MachineSpec,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _LLMDecision(BaseModel):
|
|
21
|
+
"""Provider-safe structured decision schema for live LLM calls."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
allowed: bool
|
|
27
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
28
|
+
evidence: list[str] = Field(default_factory=list)
|
|
29
|
+
uncertainties: list[str] = Field(default_factory=list)
|
|
30
|
+
alternatives: list[str] = Field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def to_decision_result(self) -> DecisionResult:
|
|
33
|
+
"""Convert the provider-safe schema into the public result model."""
|
|
34
|
+
return DecisionResult(
|
|
35
|
+
id=self.id,
|
|
36
|
+
allowed=self.allowed,
|
|
37
|
+
confidence=self.confidence,
|
|
38
|
+
evidence=self.evidence,
|
|
39
|
+
uncertainties=self.uncertainties,
|
|
40
|
+
alternatives=self.alternatives,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LLMDecisionEvaluator:
|
|
45
|
+
"""Natural-language guard and invariant evaluator backed by `LLMClient`."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client: LLMClient, *, model: str | None = None) -> None:
|
|
48
|
+
self.client = client
|
|
49
|
+
self.model = model
|
|
50
|
+
|
|
51
|
+
async def evaluate_guard(
|
|
52
|
+
self,
|
|
53
|
+
guard: GuardSpec,
|
|
54
|
+
*,
|
|
55
|
+
spec: MachineSpec,
|
|
56
|
+
snapshot: MachineSnapshot,
|
|
57
|
+
event: FSLMEvent,
|
|
58
|
+
) -> DecisionResult:
|
|
59
|
+
"""Evaluate an NL guard as a structured decision."""
|
|
60
|
+
return await self._evaluate(
|
|
61
|
+
decision_id=guard.id,
|
|
62
|
+
rule_text=guard.text,
|
|
63
|
+
rule_kind="guard",
|
|
64
|
+
spec=spec,
|
|
65
|
+
snapshot=snapshot,
|
|
66
|
+
event=event,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def check_invariant(
|
|
70
|
+
self,
|
|
71
|
+
invariant: InvariantSpec,
|
|
72
|
+
*,
|
|
73
|
+
spec: MachineSpec,
|
|
74
|
+
snapshot: MachineSnapshot,
|
|
75
|
+
event: FSLMEvent,
|
|
76
|
+
) -> DecisionResult:
|
|
77
|
+
"""Evaluate an NL invariant as a structured decision."""
|
|
78
|
+
return await self._evaluate(
|
|
79
|
+
decision_id=invariant.id,
|
|
80
|
+
rule_text=invariant.text,
|
|
81
|
+
rule_kind="invariant",
|
|
82
|
+
spec=spec,
|
|
83
|
+
snapshot=snapshot,
|
|
84
|
+
event=event,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def _evaluate(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
decision_id: str,
|
|
91
|
+
rule_text: str,
|
|
92
|
+
rule_kind: str,
|
|
93
|
+
spec: MachineSpec,
|
|
94
|
+
snapshot: MachineSnapshot,
|
|
95
|
+
event: FSLMEvent,
|
|
96
|
+
) -> DecisionResult:
|
|
97
|
+
messages = [
|
|
98
|
+
{
|
|
99
|
+
"role": "system",
|
|
100
|
+
"content": (
|
|
101
|
+
"You evaluate finite-state linguistic machine rules. Return only the "
|
|
102
|
+
"structured schema. Be conservative: if evidence is missing "
|
|
103
|
+
"or ambiguous, set allowed=false or lower confidence and "
|
|
104
|
+
"explain uncertainty."
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"role": "user",
|
|
109
|
+
"content": "\n\n".join(
|
|
110
|
+
[
|
|
111
|
+
f"Decision id: {decision_id}",
|
|
112
|
+
f"Rule kind: {rule_kind}",
|
|
113
|
+
f"Rule text:\n{rule_text}",
|
|
114
|
+
"Machine context:",
|
|
115
|
+
json.dumps(
|
|
116
|
+
{
|
|
117
|
+
"machine": spec.name,
|
|
118
|
+
"state": snapshot.current_state,
|
|
119
|
+
"variables": snapshot.variables,
|
|
120
|
+
"event": event.model_dump(mode="json"),
|
|
121
|
+
},
|
|
122
|
+
indent=2,
|
|
123
|
+
sort_keys=True,
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
result = await self.client.complete_structured(
|
|
130
|
+
messages,
|
|
131
|
+
response_model=_LLMDecision,
|
|
132
|
+
model=self.model,
|
|
133
|
+
temperature=0.0,
|
|
134
|
+
)
|
|
135
|
+
parsed = result.to_decision_result()
|
|
136
|
+
if parsed.id != decision_id:
|
|
137
|
+
return parsed.model_copy(update={"id": decision_id})
|
|
138
|
+
return parsed
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["LLMDecisionEvaluator"]
|