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,103 @@
1
+ """Small built-in binding functions for YAML-authored machines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .context import FSLMContext
8
+
9
+ _MISSING = object()
10
+
11
+
12
+ def payload_exists(ctx: FSLMContext, args: dict[str, Any]) -> bool:
13
+ """Return whether a JSON path exists in the current event payload."""
14
+ return _get_path(ctx, args["path"], missing=_MISSING) is not _MISSING
15
+
16
+
17
+ def payload_equals(ctx: FSLMContext, args: dict[str, Any]) -> bool:
18
+ """Return whether a JSON path equals a value."""
19
+ return bool(_get_path(ctx, args["path"]) == args.get("value"))
20
+
21
+
22
+ def payload_not_equals(ctx: FSLMContext, args: dict[str, Any]) -> bool:
23
+ """Return whether a JSON path does not equal a value."""
24
+ return bool(_get_path(ctx, args["path"]) != args.get("value"))
25
+
26
+
27
+ def var_exists(ctx: FSLMContext, args: dict[str, Any]) -> bool:
28
+ """Return whether a snapshot variable exists."""
29
+ return _get_path(ctx, args["path"], missing=_MISSING) is not _MISSING
30
+
31
+
32
+ def var_equals(ctx: FSLMContext, args: dict[str, Any]) -> bool:
33
+ """Return whether a snapshot variable path equals a value."""
34
+ return bool(_get_path(ctx, args["path"]) == args.get("value"))
35
+
36
+
37
+ def var_not_equals(ctx: FSLMContext, args: dict[str, Any]) -> bool:
38
+ """Return whether a snapshot variable path does not equal a value."""
39
+ return bool(_get_path(ctx, args["path"]) != args.get("value"))
40
+
41
+
42
+ def assign(_ctx: FSLMContext, args: dict[str, Any]) -> dict[str, Any]:
43
+ """Return a variable assignment patch."""
44
+ return {str(args["path"]).removeprefix("$.snapshot.variables."): args.get("value")}
45
+
46
+
47
+ def increment(ctx: FSLMContext, args: dict[str, Any]) -> dict[str, Any]:
48
+ """Return a variable increment patch."""
49
+ key = str(args["path"]).removeprefix("$.snapshot.variables.")
50
+ current = _get_path(ctx, args["path"], missing=0)
51
+ return {key: current + args.get("by", 1)}
52
+
53
+
54
+ def append(ctx: FSLMContext, args: dict[str, Any]) -> dict[str, Any]:
55
+ """Return a variable list-append patch."""
56
+ key = str(args["path"]).removeprefix("$.snapshot.variables.")
57
+ current = _get_path(ctx, args["path"], missing=[])
58
+ if not isinstance(current, list):
59
+ current = [current]
60
+ return {key: [*current, args.get("value")]}
61
+
62
+
63
+ def copy_payload_to_var(ctx: FSLMContext, args: dict[str, Any]) -> dict[str, Any]:
64
+ """Copy an event payload value to a snapshot variable."""
65
+ value = _get_path(ctx, args["payload_path"])
66
+ key = str(args["var_path"]).removeprefix("$.snapshot.variables.")
67
+ return {key: value}
68
+
69
+
70
+ def _get_path(ctx: FSLMContext, path: str, *, missing: Any = None) -> Any:
71
+ root: Any
72
+ if path.startswith("$.event.payload"):
73
+ root = ctx.event.payload
74
+ parts = path.removeprefix("$.event.payload").strip(".").split(".")
75
+ elif path.startswith("$.snapshot.variables"):
76
+ root = ctx.snapshot.variables
77
+ parts = path.removeprefix("$.snapshot.variables").strip(".").split(".")
78
+ else:
79
+ root = {"event": {"payload": ctx.event.payload}, "snapshot": {"variables": ctx.snapshot.variables}}
80
+ parts = path.removeprefix("$.").split(".")
81
+ if parts == [""]:
82
+ return root
83
+ current = root
84
+ for part in parts:
85
+ if isinstance(current, dict) and part in current:
86
+ current = current[part]
87
+ else:
88
+ return missing
89
+ return current
90
+
91
+
92
+ __all__ = [
93
+ "append",
94
+ "assign",
95
+ "copy_payload_to_var",
96
+ "increment",
97
+ "payload_equals",
98
+ "payload_exists",
99
+ "payload_not_equals",
100
+ "var_equals",
101
+ "var_exists",
102
+ "var_not_equals",
103
+ ]
ellements/fslm/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """`fslm` command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Literal, cast
12
+
13
+ from .definition import MachineDefinition
14
+ from .kernel import FSLMKernel
15
+ from .loading import load_machine_definition
16
+ from .models import FSLMEvent, MachineSnapshot
17
+ from .rendering import ColorMode, render_snapshot, render_step, render_validation
18
+ from .visualization import to_mermaid
19
+
20
+ OutputFormat = Literal["json", "rich"]
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(
25
+ prog="fslm",
26
+ description="Run and inspect ellements finite-state linguistic machines.",
27
+ )
28
+ sub = parser.add_subparsers(dest="command", required=True)
29
+
30
+ validate = sub.add_parser("validate", help="Validate a Python-defined machine.")
31
+ validate.add_argument("spec")
32
+ validate.add_argument("--bindings", action="append", default=None)
33
+ _add_human_output_options(validate)
34
+ validate.set_defaults(handler=_validate)
35
+
36
+ mermaid = sub.add_parser("mermaid", help="Render a spec as Mermaid.")
37
+ mermaid.add_argument("spec")
38
+ mermaid.add_argument("--bindings", action="append", default=None)
39
+ mermaid.set_defaults(handler=_mermaid)
40
+
41
+ init_state = sub.add_parser("init-state", help="Create an initial snapshot.")
42
+ init_state.add_argument("spec")
43
+ init_state.add_argument("--bindings", action="append", default=None)
44
+ init_state.add_argument("--machine-id", default=None)
45
+ init_state.add_argument("--seed", type=int, default=None)
46
+ init_state.add_argument("--state-dir", type=Path, default=None)
47
+ _add_human_output_options(init_state)
48
+ init_state.set_defaults(handler=_init_state)
49
+
50
+ inspect = sub.add_parser("inspect", help="Print a snapshot JSON file.")
51
+ inspect.add_argument("snapshot", type=Path)
52
+ inspect.add_argument("--spec", default=None, help="Optional machine spec for state objectives.")
53
+ _add_human_output_options(inspect)
54
+ inspect.set_defaults(handler=_inspect)
55
+
56
+ step = sub.add_parser("step", help="Run one machine step.")
57
+ step.add_argument("spec")
58
+ step.add_argument("--bindings", action="append", default=None)
59
+ step.add_argument("--snapshot", type=Path)
60
+ step.add_argument("--state-dir", type=Path, default=None)
61
+ step.add_argument("--event", type=Path, required=True)
62
+ _add_human_output_options(step)
63
+ step.set_defaults(handler=_step)
64
+ return parser
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ parser = build_parser()
69
+ args = parser.parse_args(argv)
70
+ try:
71
+ handler: Callable[[argparse.Namespace], int] = args.handler
72
+ return handler(args)
73
+ except Exception as exc:
74
+ sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
75
+ return 1
76
+
77
+
78
+ def _validate(args: argparse.Namespace) -> int:
79
+ spec = _load_definition(args).spec
80
+ if _output_format(args) == "rich":
81
+ render_validation(spec, color=_color_mode(args))
82
+ else:
83
+ print(json.dumps({"ok": True, "machine": spec.name}, sort_keys=True))
84
+ return 0
85
+
86
+
87
+ def _mermaid(args: argparse.Namespace) -> int:
88
+ print(to_mermaid(_load_definition(args).spec), end="")
89
+ return 0
90
+
91
+
92
+ def _init_state(args: argparse.Namespace) -> int:
93
+ spec = _load_definition(args).spec
94
+ snapshot = spec.initial_snapshot(
95
+ machine_id=args.machine_id,
96
+ random_seed=args.seed,
97
+ )
98
+ if args.state_dir is not None:
99
+ args.state_dir.mkdir(parents=True, exist_ok=True)
100
+ (args.state_dir / "snapshot.json").write_text(
101
+ json.dumps(snapshot.model_dump(mode="json"), indent=2, sort_keys=True),
102
+ encoding="utf-8",
103
+ )
104
+ if _output_format(args) == "rich":
105
+ render_snapshot(
106
+ snapshot,
107
+ spec=spec,
108
+ title="FSLM Initial State",
109
+ color=_color_mode(args),
110
+ )
111
+ else:
112
+ print(json.dumps(snapshot.model_dump(mode="json"), indent=2, sort_keys=True))
113
+ return 0
114
+
115
+
116
+ def _inspect(args: argparse.Namespace) -> int:
117
+ snapshot = MachineSnapshot.model_validate(
118
+ json.loads(args.snapshot.read_text(encoding="utf-8"))
119
+ )
120
+ if _output_format(args) == "rich":
121
+ spec = _load_definition(args).spec if args.spec is not None else None
122
+ render_snapshot(snapshot, spec=spec, color=_color_mode(args))
123
+ else:
124
+ print(json.dumps(snapshot.model_dump(mode="json"), indent=2, sort_keys=True))
125
+ return 0
126
+
127
+
128
+ def _step(args: argparse.Namespace) -> int:
129
+ definition = _load_definition(args)
130
+ snapshot_path = args.snapshot
131
+ if snapshot_path is None and args.state_dir is not None:
132
+ snapshot_path = args.state_dir / "snapshot.json"
133
+ if snapshot_path is None:
134
+ raise ValueError("--snapshot or --state-dir is required")
135
+ snapshot = MachineSnapshot.model_validate(json.loads(snapshot_path.read_text("utf-8")))
136
+ event = FSLMEvent.model_validate(json.loads(args.event.read_text(encoding="utf-8")))
137
+ result = asyncio.run(FSLMKernel(definition).step(snapshot, event))
138
+ if args.state_dir is not None:
139
+ args.state_dir.mkdir(parents=True, exist_ok=True)
140
+ (args.state_dir / "snapshot.json").write_text(
141
+ json.dumps(
142
+ result.new_snapshot.model_dump(mode="json"),
143
+ indent=2,
144
+ sort_keys=True,
145
+ ),
146
+ encoding="utf-8",
147
+ )
148
+ if _output_format(args) == "rich":
149
+ render_step(
150
+ result,
151
+ definition=definition,
152
+ event=event,
153
+ state_dir=args.state_dir,
154
+ color=_color_mode(args),
155
+ )
156
+ else:
157
+ print(json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=True))
158
+ return 0
159
+
160
+
161
+ def _add_human_output_options(parser: argparse.ArgumentParser) -> None:
162
+ parser.add_argument(
163
+ "--format",
164
+ choices=("json", "rich"),
165
+ default="json",
166
+ help="Output format. JSON is stable for automation; rich is designed for humans.",
167
+ )
168
+ parser.add_argument(
169
+ "--color",
170
+ choices=("auto", "always", "never"),
171
+ default="auto",
172
+ help="Color handling for rich output.",
173
+ )
174
+
175
+
176
+ def _output_format(args: argparse.Namespace) -> OutputFormat:
177
+ value = args.format
178
+ if value not in {"json", "rich"}:
179
+ raise ValueError(f"unsupported output format: {value}")
180
+ return cast(OutputFormat, value)
181
+
182
+
183
+ def _color_mode(args: argparse.Namespace) -> ColorMode:
184
+ value = args.color
185
+ if value not in {"auto", "always", "never"}:
186
+ raise ValueError(f"unsupported color mode: {value}")
187
+ return cast(ColorMode, value)
188
+
189
+
190
+ def _load_definition(args: argparse.Namespace) -> MachineDefinition:
191
+ bindings = getattr(args, "bindings", None)
192
+ return load_machine_definition(args.spec, binding_modules=list(bindings or []))
193
+
194
+
195
+ if __name__ == "__main__":
196
+ raise SystemExit(main())
197
+
198
+
199
+ __all__ = ["build_parser", "main"]
@@ -0,0 +1,50 @@
1
+ """Runtime context passed to FSLM callables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from ellements.core import ToolRegistry
10
+
11
+ from .definition import MachineDefinition
12
+ from .models import (
13
+ FSLMEvent,
14
+ MachineSnapshot,
15
+ MachineSpec,
16
+ StateSpec,
17
+ TransitionSpec,
18
+ )
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class FSLMContext:
23
+ """Context object passed to deterministic and NL hooks."""
24
+
25
+ spec: MachineSpec
26
+ definition: MachineDefinition
27
+ state: StateSpec
28
+ transition: TransitionSpec | None
29
+ snapshot: MachineSnapshot
30
+ event: FSLMEvent
31
+ vars: Mapping[str, Any]
32
+ step_id: str
33
+ run_id: str | None = None
34
+ metadata: Mapping[str, Any] = field(default_factory=dict)
35
+
36
+ @property
37
+ def tools(self) -> ToolRegistry | None:
38
+ """Tool registry available to this machine, if any."""
39
+ return self.definition.bindings.tools
40
+
41
+ async def call_tool(self, name: str, **arguments: Any) -> Any:
42
+ """Invoke a registered ellements tool by name."""
43
+ if self.tools is None:
44
+ raise KeyError(f"no tool registry configured for {name!r}")
45
+ if name not in self.tools:
46
+ raise KeyError(f"tool {name!r} is not registered")
47
+ return await self.tools[name].invoke(**arguments)
48
+
49
+
50
+ __all__ = ["FSLMContext"]
@@ -0,0 +1,163 @@
1
+ """Executable FSLM definitions and runtime bindings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from types import ModuleType
9
+ from typing import Any, cast
10
+
11
+ from ellements.core import ToolRegistry
12
+
13
+ from .models import MachineSpec
14
+
15
+ BindingCallable = Callable[..., Any]
16
+
17
+
18
+ @dataclass
19
+ class RuntimeBindings:
20
+ """Non-serializable runtime objects referenced by a `MachineSpec`."""
21
+
22
+ guards: dict[str, BindingCallable] = field(default_factory=dict)
23
+ invariants: dict[str, BindingCallable] = field(default_factory=dict)
24
+ effects: dict[str, BindingCallable] = field(default_factory=dict)
25
+ actions: dict[str, BindingCallable] = field(default_factory=dict)
26
+ outputs: dict[str, BindingCallable] = field(default_factory=dict)
27
+ evaluators: dict[str, Any] = field(default_factory=dict)
28
+ modules: dict[str, ModuleType] = field(default_factory=dict)
29
+ tools: ToolRegistry | None = None
30
+
31
+ def merge(self, other: RuntimeBindings) -> RuntimeBindings:
32
+ """Return a binding set with `other` taking precedence."""
33
+ return RuntimeBindings(
34
+ guards={**self.guards, **other.guards},
35
+ invariants={**self.invariants, **other.invariants},
36
+ effects={**self.effects, **other.effects},
37
+ actions={**self.actions, **other.actions},
38
+ outputs={**self.outputs, **other.outputs},
39
+ evaluators={**self.evaluators, **other.evaluators},
40
+ modules={**self.modules, **other.modules},
41
+ tools=other.tools or self.tools,
42
+ )
43
+
44
+ def resolve(self, ref: str, category: str) -> BindingCallable:
45
+ """Resolve a binding reference for one callable category."""
46
+ registry = self._registry(category)
47
+ if ref in registry:
48
+ return registry[ref]
49
+ if ref.startswith("builtin."):
50
+ return _builtin(ref)
51
+ if "." in ref:
52
+ alias, _, name = ref.partition(".")
53
+ module = self.modules.get(alias)
54
+ if module is not None and hasattr(module, name):
55
+ value = getattr(module, name)
56
+ if callable(value):
57
+ return cast(BindingCallable, value)
58
+ raise TypeError(f"binding {ref!r} is not callable")
59
+ imported = _import_ref(ref)
60
+ if imported is not None:
61
+ return imported
62
+ raise KeyError(f"unresolved {category} binding reference {ref!r}")
63
+
64
+ def _registry(self, category: str) -> dict[str, BindingCallable]:
65
+ registries = {
66
+ "guard": self.guards,
67
+ "invariant": self.invariants,
68
+ "effect": self.effects,
69
+ "action": self.actions,
70
+ "output": self.outputs,
71
+ }
72
+ return registries.get(category, {})
73
+
74
+
75
+ @dataclass
76
+ class MachineDefinition:
77
+ """Executable machine definition: serializable spec plus bindings."""
78
+
79
+ spec: MachineSpec
80
+ bindings: RuntimeBindings = field(default_factory=RuntimeBindings)
81
+
82
+ def __getattr__(self, name: str) -> Any:
83
+ return getattr(self.spec, name)
84
+
85
+ def initial_snapshot(self, **kwargs: Any) -> Any:
86
+ """Delegate initial snapshot creation to the spec."""
87
+ return self.spec.initial_snapshot(**kwargs)
88
+
89
+ def to_json_file(self, path: str) -> None:
90
+ """Write the serializable spec as JSON."""
91
+ self.spec.to_json_file(path)
92
+
93
+
94
+ def coerce_definition(
95
+ value: MachineDefinition | MachineSpec | Any,
96
+ *,
97
+ bindings: RuntimeBindings | None = None,
98
+ ) -> MachineDefinition:
99
+ """Normalize specs/builders/callables into a `MachineDefinition`."""
100
+ if isinstance(value, MachineDefinition):
101
+ if bindings is None:
102
+ return value
103
+ return MachineDefinition(value.spec, value.bindings.merge(bindings))
104
+ if isinstance(value, MachineSpec):
105
+ return MachineDefinition(value, bindings or RuntimeBindings())
106
+ build = getattr(value, "build", None)
107
+ if callable(build):
108
+ return coerce_definition(build(), bindings=bindings)
109
+ if callable(value):
110
+ return coerce_definition(value(), bindings=bindings)
111
+ raise TypeError(
112
+ "expected MachineDefinition, MachineSpec, builder with build(), "
113
+ "or callable returning one"
114
+ )
115
+
116
+
117
+ def bindings_from_mapping(mapping: Mapping[str, Mapping[str, BindingCallable]]) -> RuntimeBindings:
118
+ """Build bindings from a nested mapping keyed by category."""
119
+ return RuntimeBindings(
120
+ guards=dict(mapping.get("guards", {})),
121
+ invariants=dict(mapping.get("invariants", {})),
122
+ effects=dict(mapping.get("effects", {})),
123
+ actions=dict(mapping.get("actions", {})),
124
+ outputs=dict(mapping.get("outputs", {})),
125
+ )
126
+
127
+
128
+ def _builtin(ref: str) -> BindingCallable:
129
+ from . import builtins
130
+
131
+ name = ref.removeprefix("builtin.")
132
+ value = getattr(builtins, name, None)
133
+ if callable(value):
134
+ return cast(BindingCallable, value)
135
+ raise KeyError(f"unknown built-in binding {ref!r}")
136
+
137
+
138
+ def _import_ref(ref: str) -> BindingCallable | None:
139
+ module_name: str
140
+ attr_name: str
141
+ if ":" in ref:
142
+ module_name, attr_name = ref.rsplit(":", 1)
143
+ elif "." in ref:
144
+ module_name, attr_name = ref.rsplit(".", 1)
145
+ else:
146
+ return None
147
+ try:
148
+ module = importlib.import_module(module_name)
149
+ except ImportError:
150
+ return None
151
+ value = getattr(module, attr_name)
152
+ if not callable(value):
153
+ raise TypeError(f"binding {ref!r} is not callable")
154
+ return cast(BindingCallable, value)
155
+
156
+
157
+ __all__ = [
158
+ "BindingCallable",
159
+ "MachineDefinition",
160
+ "RuntimeBindings",
161
+ "bindings_from_mapping",
162
+ "coerce_definition",
163
+ ]
ellements/fslm/det.py ADDED
@@ -0,0 +1,173 @@
1
+ """Deterministic helper namespace for FSLM definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .models import ActionSpec, EffectSpec, GuardSpec, InvariantSpec, OutputSpec
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class BoundGuard:
14
+ spec: GuardSpec
15
+ fn: Callable[..., Any]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class BoundInvariant:
20
+ spec: InvariantSpec
21
+ fn: Callable[..., Any]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class BoundOutput:
26
+ spec: OutputSpec
27
+ fn: Callable[..., Any]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class BoundAction:
32
+ spec: ActionSpec
33
+ fn: Callable[..., Any] | None = None
34
+ argument_factory: Callable[..., Any] | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BoundEffect:
39
+ spec: EffectSpec
40
+ fn: Callable[..., Any]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Increment:
45
+ path: str
46
+ by: int | float = 1
47
+
48
+
49
+ def guard(
50
+ id: str,
51
+ fn: Callable[..., Any] | None = None,
52
+ *,
53
+ ref: str | None = None,
54
+ args: Mapping[str, Any] | None = None,
55
+ min_confidence: float | None = None,
56
+ ) -> GuardSpec | BoundGuard:
57
+ """Declare a deterministic guard."""
58
+ spec = GuardSpec(
59
+ id=id,
60
+ kind="deterministic",
61
+ ref=ref or id,
62
+ args=dict(args or {}),
63
+ min_confidence=min_confidence,
64
+ )
65
+ return BoundGuard(spec, fn) if fn is not None else spec
66
+
67
+
68
+ def invariant(
69
+ id: str,
70
+ fn: Callable[..., Any] | None = None,
71
+ *,
72
+ ref: str | None = None,
73
+ args: Mapping[str, Any] | None = None,
74
+ severity: str = "error",
75
+ ) -> InvariantSpec | BoundInvariant:
76
+ """Declare a deterministic invariant."""
77
+ spec = InvariantSpec(
78
+ id=id,
79
+ kind="deterministic",
80
+ ref=ref or id,
81
+ args=dict(args or {}),
82
+ severity=severity, # type: ignore[arg-type]
83
+ )
84
+ return BoundInvariant(spec, fn) if fn is not None else spec
85
+
86
+
87
+ def output(
88
+ type: str,
89
+ fn: Callable[..., Any] | None = None,
90
+ *,
91
+ ref: str | None = None,
92
+ args: Mapping[str, Any] | None = None,
93
+ description: str = "",
94
+ ) -> OutputSpec | BoundOutput:
95
+ """Declare a deterministic output."""
96
+ spec = OutputSpec(
97
+ type=type,
98
+ kind="deterministic",
99
+ ref=ref or type,
100
+ args=dict(args or {}),
101
+ description=description,
102
+ )
103
+ return BoundOutput(spec, fn) if fn is not None else spec
104
+
105
+
106
+ def action(
107
+ tool: str,
108
+ *,
109
+ name: str | None = None,
110
+ arguments: Mapping[str, Any] | Callable[..., Any] | None = None,
111
+ requires_safety_check: bool = False,
112
+ args: Mapping[str, Any] | None = None,
113
+ ) -> BoundAction | ActionSpec:
114
+ """Declare a deterministic tool action."""
115
+ spec = ActionSpec(
116
+ name=name or tool,
117
+ kind="tool",
118
+ tool=tool,
119
+ arguments=dict(arguments or {}) if isinstance(arguments, Mapping) else {},
120
+ args=dict(args or {}),
121
+ requires_approval=requires_safety_check,
122
+ )
123
+ if callable(arguments):
124
+ return BoundAction(spec, argument_factory=arguments)
125
+ return spec
126
+
127
+
128
+ def call(
129
+ name: str,
130
+ fn: Callable[..., Any],
131
+ *,
132
+ args: Mapping[str, Any] | None = None,
133
+ ) -> BoundAction:
134
+ """Declare a deterministic side-effectful Python call."""
135
+ spec = ActionSpec(
136
+ name=name,
137
+ kind="deterministic",
138
+ ref=name,
139
+ args=dict(args or {}),
140
+ )
141
+ return BoundAction(spec, fn=fn)
142
+
143
+
144
+ def effect(
145
+ id: str,
146
+ fn: Callable[..., Any],
147
+ *,
148
+ args: Mapping[str, Any] | None = None,
149
+ ) -> BoundEffect:
150
+ """Declare a pure deterministic effect function."""
151
+ return BoundEffect(EffectSpec(id=id, ref=id, args=dict(args or {})), fn)
152
+
153
+
154
+ def increment(path: str, by: int | float = 1) -> Increment:
155
+ """Declare an increment effect shorthand for `.effects(...)`."""
156
+ return Increment(path=path, by=by)
157
+
158
+
159
+ __all__ = [
160
+ "BoundAction",
161
+ "BoundEffect",
162
+ "BoundGuard",
163
+ "BoundInvariant",
164
+ "BoundOutput",
165
+ "Increment",
166
+ "action",
167
+ "call",
168
+ "effect",
169
+ "guard",
170
+ "increment",
171
+ "invariant",
172
+ "output",
173
+ ]