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
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"]
@@ -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"]