fluxrules 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 (241) hide show
  1. fluxrules/__init__.py +209 -0
  2. fluxrules/adapters/__init__.py +3 -0
  3. fluxrules/adapters/caches/__init__.py +100 -0
  4. fluxrules/adapters/loaders/__init__.py +5 -0
  5. fluxrules/adapters/loaders/yaml_loader.py +47 -0
  6. fluxrules/adapters/observability/__init__.py +3 -0
  7. fluxrules/adapters/observability/noop.py +27 -0
  8. fluxrules/adapters/persistence/__init__.py +7 -0
  9. fluxrules/adapters/persistence/sqlalchemy_adapter.py +168 -0
  10. fluxrules/adapters/queues/__init__.py +101 -0
  11. fluxrules/adapters/repository/__init__.py +15 -0
  12. fluxrules/adapters/repository/database.py +59 -0
  13. fluxrules/adapters/repository/in_memory.py +60 -0
  14. fluxrules/analytics/__init__.py +11 -0
  15. fluxrules/api/__init__.py +10 -0
  16. fluxrules/api/analytics/__init__.py +1 -0
  17. fluxrules/api/analytics/coverage_report.py +20 -0
  18. fluxrules/api/analytics/explanation_engine.py +21 -0
  19. fluxrules/api/analytics/metrics.py +23 -0
  20. fluxrules/api/analytics/store.py +149 -0
  21. fluxrules/api/app.py +108 -0
  22. fluxrules/api/config.py +136 -0
  23. fluxrules/api/database.py +172 -0
  24. fluxrules/api/deps.py +69 -0
  25. fluxrules/api/engines/__init__.py +16 -0
  26. fluxrules/api/engines/adapter.py +434 -0
  27. fluxrules/api/engines/engine_pool.py +229 -0
  28. fluxrules/api/engines/holder.py +0 -0
  29. fluxrules/api/handlers.py +110 -0
  30. fluxrules/api/models/__init__.py +7 -0
  31. fluxrules/api/models/audit.py +20 -0
  32. fluxrules/api/models/audit_policy.py +65 -0
  33. fluxrules/api/models/conflicted_rule.py +44 -0
  34. fluxrules/api/models/rule.py +72 -0
  35. fluxrules/api/models/user.py +24 -0
  36. fluxrules/api/routes/__init__.py +0 -0
  37. fluxrules/api/routes/admin.py +65 -0
  38. fluxrules/api/routes/analytics.py +53 -0
  39. fluxrules/api/routes/audit.py +56 -0
  40. fluxrules/api/routes/audit_policy.py +160 -0
  41. fluxrules/api/routes/auth.py +61 -0
  42. fluxrules/api/routes/brms_analyze.py +525 -0
  43. fluxrules/api/routes/dependency_graph.py +203 -0
  44. fluxrules/api/routes/engines.py +397 -0
  45. fluxrules/api/routes/evaluate.py +42 -0
  46. fluxrules/api/routes/evaluation.py +219 -0
  47. fluxrules/api/routes/events.py +97 -0
  48. fluxrules/api/routes/explain.py +25 -0
  49. fluxrules/api/routes/health.py +37 -0
  50. fluxrules/api/routes/metrics.py +49 -0
  51. fluxrules/api/routes/rules.py +1119 -0
  52. fluxrules/api/routes/rulesets.py +109 -0
  53. fluxrules/api/routes/sdk.py +109 -0
  54. fluxrules/api/routes/sessions.py +205 -0
  55. fluxrules/api/routes/simulate.py +28 -0
  56. fluxrules/api/routes/validate.py +34 -0
  57. fluxrules/api/schema_manager.py +98 -0
  58. fluxrules/api/schemas/__init__.py +30 -0
  59. fluxrules/api/schemas/analytics.py +57 -0
  60. fluxrules/api/schemas/audit_policy.py +79 -0
  61. fluxrules/api/schemas/event.py +15 -0
  62. fluxrules/api/schemas/rule.py +98 -0
  63. fluxrules/api/schemas/session.py +38 -0
  64. fluxrules/api/schemas/user.py +37 -0
  65. fluxrules/api/schemas.py +30 -0
  66. fluxrules/api/security.py +82 -0
  67. fluxrules/api/services/__init__.py +0 -0
  68. fluxrules/api/services/analytics_service.py +207 -0
  69. fluxrules/api/services/audit_runner.py +265 -0
  70. fluxrules/api/services/audit_scheduler.py +157 -0
  71. fluxrules/api/services/audit_service.py +118 -0
  72. fluxrules/api/services/auth_service.py +38 -0
  73. fluxrules/api/services/brms_service.py +135 -0
  74. fluxrules/api/services/rule_service.py +196 -0
  75. fluxrules/api/services/rule_validation_service.py +468 -0
  76. fluxrules/api/utils/__init__.py +0 -0
  77. fluxrules/api/utils/metrics.py +198 -0
  78. fluxrules/api/utils/redis_client.py +38 -0
  79. fluxrules/api/workers/__init__.py +0 -0
  80. fluxrules/api/workers/event_worker.py +83 -0
  81. fluxrules/api/workers/validation_worker.py +212 -0
  82. fluxrules/cli/__init__.py +0 -0
  83. fluxrules/cli/main.py +200 -0
  84. fluxrules/compat/__init__.py +3 -0
  85. fluxrules/compat/contracts.py +17 -0
  86. fluxrules/config/__init__.py +208 -0
  87. fluxrules/config/deployment.py +355 -0
  88. fluxrules/config/manager.py +77 -0
  89. fluxrules/core/__init__.py +11 -0
  90. fluxrules/di/__init__.py +334 -0
  91. fluxrules/domain/__init__.py +35 -0
  92. fluxrules/domain/actions.py +338 -0
  93. fluxrules/domain/dsl/__init__.py +179 -0
  94. fluxrules/domain/dsl/evaluator.py +180 -0
  95. fluxrules/domain/dsl/expression_parser.py +94 -0
  96. fluxrules/domain/dsl/parser.py +147 -0
  97. fluxrules/domain/dsl/validation.py +183 -0
  98. fluxrules/domain/errors.py +108 -0
  99. fluxrules/domain/factory.py +177 -0
  100. fluxrules/domain/models.py +236 -0
  101. fluxrules/domain/predicates.py +37 -0
  102. fluxrules/domain/rule_builder.py +462 -0
  103. fluxrules/domain/schemas.py +87 -0
  104. fluxrules/domain/unified_rule.py +456 -0
  105. fluxrules/engine/__init__.py +156 -0
  106. fluxrules/engine/base.py +339 -0
  107. fluxrules/engine/configuration.py +61 -0
  108. fluxrules/engine/cross_fact/__init__.py +48 -0
  109. fluxrules/engine/cross_fact/_engine.py +574 -0
  110. fluxrules/engine/cross_fact/models.py +324 -0
  111. fluxrules/engine/cross_fact/working_memory.py +133 -0
  112. fluxrules/engine/infrastructure/__init__.py +72 -0
  113. fluxrules/engine/infrastructure/agenda.py +111 -0
  114. fluxrules/engine/infrastructure/bitmask_linker.py +363 -0
  115. fluxrules/engine/infrastructure/engine_metrics.py +146 -0
  116. fluxrules/engine/infrastructure/evaluation_filter.py +26 -0
  117. fluxrules/engine/infrastructure/evaluation_result.py +15 -0
  118. fluxrules/engine/infrastructure/fact_store.py +184 -0
  119. fluxrules/engine/infrastructure/field_index.py +85 -0
  120. fluxrules/engine/infrastructure/global_rule_repository.py +329 -0
  121. fluxrules/engine/infrastructure/lazy_evaluation.py +173 -0
  122. fluxrules/engine/infrastructure/node_memory.py +178 -0
  123. fluxrules/engine/infrastructure/scope_guard.py +402 -0
  124. fluxrules/engine/infrastructure/segment_network.py +139 -0
  125. fluxrules/engine/infrastructure/token_propagator.py +62 -0
  126. fluxrules/engine/infrastructure/working_memory.py +141 -0
  127. fluxrules/engine/interfaces.py +13 -0
  128. fluxrules/engine/operator_registry.py +83 -0
  129. fluxrules/engine/operators.py +175 -0
  130. fluxrules/engine/phreak/__init__.py +34 -0
  131. fluxrules/engine/phreak/_engine.py +1333 -0
  132. fluxrules/engine/phreak/condition_compiler.py +204 -0
  133. fluxrules/engine/profiler.py +62 -0
  134. fluxrules/engine/runtime/__init__.py +43 -0
  135. fluxrules/engine/runtime/action_dispatcher.py +277 -0
  136. fluxrules/engine/runtime/dedup_cache.py +247 -0
  137. fluxrules/engine/runtime/worker_pool.py +120 -0
  138. fluxrules/exceptions.py +132 -0
  139. fluxrules/features.py +61 -0
  140. fluxrules/initialization.py +111 -0
  141. fluxrules/logging.py +70 -0
  142. fluxrules/persistence/__init__.py +27 -0
  143. fluxrules/persistence/database_config.py +141 -0
  144. fluxrules/persistence/db_connection_manager.py +201 -0
  145. fluxrules/persistence/execution_repository.py +67 -0
  146. fluxrules/persistence/mappers.py +178 -0
  147. fluxrules/persistence/persistence_manager.py +229 -0
  148. fluxrules/persistence/rule_repository.py +131 -0
  149. fluxrules/pipeline/__init__.py +156 -0
  150. fluxrules/pipeline/base.py +179 -0
  151. fluxrules/pipeline/core.py +410 -0
  152. fluxrules/pipeline/error_policy.py +106 -0
  153. fluxrules/pipeline/exporters.py +320 -0
  154. fluxrules/pipeline/loaders.py +173 -0
  155. fluxrules/pipeline/observability.py +206 -0
  156. fluxrules/pipeline/registry.py +96 -0
  157. fluxrules/pipeline/schema.py +118 -0
  158. fluxrules/pipeline/transforms/__init__.py +33 -0
  159. fluxrules/pipeline/transforms/builtin.py +240 -0
  160. fluxrules/pipeline/transforms/composed.py +196 -0
  161. fluxrules/pipeline/utils/__init__.py +13 -0
  162. fluxrules/pipeline/utils/dict_utils.py +189 -0
  163. fluxrules/pipeline/validators.py +128 -0
  164. fluxrules/pipeline/versioning.py +137 -0
  165. fluxrules/plugins/__init__.py +0 -0
  166. fluxrules/plugins/actions.py +446 -0
  167. fluxrules/plugins/discovery.py +106 -0
  168. fluxrules/plugins/operators.py +88 -0
  169. fluxrules/ports/__init__.py +19 -0
  170. fluxrules/ports/circuit_breaker.py +35 -0
  171. fluxrules/ports/execution_store.py +22 -0
  172. fluxrules/ports/health.py +33 -0
  173. fluxrules/ports/observability.py +30 -0
  174. fluxrules/ports/persistence.py +97 -0
  175. fluxrules/ports/queue.py +78 -0
  176. fluxrules/ports/repository.py +27 -0
  177. fluxrules/ports/rule.py +23 -0
  178. fluxrules/ports/rule_cache.py +69 -0
  179. fluxrules/ports/validation.py +44 -0
  180. fluxrules/prefilter/__init__.py +76 -0
  181. fluxrules/prefilter/pipeline.py +115 -0
  182. fluxrules/prefilter/predicate.py +304 -0
  183. fluxrules/prefilter/predicate_index.py +307 -0
  184. fluxrules/prefilter/sql_store.py +238 -0
  185. fluxrules/py.typed +0 -0
  186. fluxrules/services/__init__.py +4 -0
  187. fluxrules/services/audit_service.py +29 -0
  188. fluxrules/services/circuit_breaker.py +98 -0
  189. fluxrules/services/compilation/__init__.py +7 -0
  190. fluxrules/services/compilation/rule_compiler.py +81 -0
  191. fluxrules/services/evaluation_service.py +21 -0
  192. fluxrules/services/execution/__init__.py +25 -0
  193. fluxrules/services/execution/agenda.py +51 -0
  194. fluxrules/services/execution/scheduler.py +37 -0
  195. fluxrules/services/execution/session_context.py +187 -0
  196. fluxrules/services/execution/session_manager.py +242 -0
  197. fluxrules/services/execution/session_storage.py +227 -0
  198. fluxrules/services/execution/storage_backend.py +302 -0
  199. fluxrules/services/execution/working_memory.py +96 -0
  200. fluxrules/services/health.py +59 -0
  201. fluxrules/services/persistence_service.py +67 -0
  202. fluxrules/services/reference_evaluator.py +127 -0
  203. fluxrules/services/rule_loader.py +73 -0
  204. fluxrules/services/rule_service.py +229 -0
  205. fluxrules/services/session.py +105 -0
  206. fluxrules/services/validation/__init__.py +39 -0
  207. fluxrules/services/validation/_compiled_cache.py +230 -0
  208. fluxrules/services/validation/_interval_index.py +200 -0
  209. fluxrules/services/validation/_normalization.py +110 -0
  210. fluxrules/services/validation/conflict_detection.py +375 -0
  211. fluxrules/services/validation/coverage_analysis.py +55 -0
  212. fluxrules/services/validation/dead_rule_detection.py +75 -0
  213. fluxrules/services/validation/duplicate_detection.py +24 -0
  214. fluxrules/services/validation/gap_detection.py +40 -0
  215. fluxrules/services/validation/priority_collision_detection.py +26 -0
  216. fluxrules/services/validation/redundancy_detection.py +42 -0
  217. fluxrules/services/validation/sat_validation.py +281 -0
  218. fluxrules/services/validation_enforcer.py +142 -0
  219. fluxrules/services/validation_gateway.py +213 -0
  220. fluxrules/services/validation_service.py +115 -0
  221. fluxrules/services/validators/__init__.py +35 -0
  222. fluxrules/services/validators/action_validator.py +73 -0
  223. fluxrules/services/validators/base.py +207 -0
  224. fluxrules/services/validators/brms_validator.py +151 -0
  225. fluxrules/services/validators/duplicate_validator.py +139 -0
  226. fluxrules/services/validators/priority_validator.py +90 -0
  227. fluxrules/services/validators/referential_validator.py +83 -0
  228. fluxrules/services/validators/stateful_dsl_validator.py +195 -0
  229. fluxrules/services/validators/structural_validator.py +199 -0
  230. fluxrules/testing/__init__.py +193 -0
  231. fluxrules/utils/__init__.py +16 -0
  232. fluxrules/utils/dependency_graph.py +67 -0
  233. fluxrules/utils/dependency_graph_analyzer.py +114 -0
  234. fluxrules/utils/id_generators.py +379 -0
  235. fluxrules/utils/metrics.py +93 -0
  236. fluxrules/version.py +5 -0
  237. fluxrules-0.1.0.dist-info/METADATA +274 -0
  238. fluxrules-0.1.0.dist-info/RECORD +241 -0
  239. fluxrules-0.1.0.dist-info/WHEEL +4 -0
  240. fluxrules-0.1.0.dist-info/entry_points.txt +2 -0
  241. fluxrules-0.1.0.dist-info/licenses/LICENSE +21 -0
fluxrules/__init__.py ADDED
@@ -0,0 +1,209 @@
1
+ """Public package interface for fluxrules core.
2
+
3
+ Main API Functions:
4
+ evaluate() - Evaluate rules against facts
5
+ validate() - Validate rules
6
+ explain() - Get explanation for execution
7
+
8
+ Every evaluation path returns the one :class:`EvaluationResult` type, whose
9
+ ``fired_rules`` holds the rules that matched::
10
+
11
+ from fluxrules import Rule, evaluate
12
+
13
+ rule = Rule(
14
+ name="adult",
15
+ condition_dsl={"type": "condition", "field": "age", "op": ">=", "value": 18},
16
+ action="allow",
17
+ )
18
+ evaluate(rule, {"age": 30}).fired_rules
19
+
20
+ For engine implementations, import directly from their packages:
21
+ from fluxrules.engine.phreak import PhreakEngine
22
+
23
+ Infrastructure:
24
+ from fluxrules.engine import BaseEngine
25
+ """
26
+
27
+ from collections.abc import Iterable
28
+ from typing import Any
29
+
30
+ from fluxrules.config.deployment import (
31
+ ConfigValidationError,
32
+ DeploymentConfig,
33
+ DeploymentType,
34
+ )
35
+ from fluxrules.domain.errors import (
36
+ CorruptStoredRuleError,
37
+ CyclicDependencyError,
38
+ DSLValidationError,
39
+ EmptyRuleLogicError,
40
+ FluxRulesError,
41
+ InvalidRuleError,
42
+ LossyConditionRebuildError,
43
+ LossyConditionViewWarning,
44
+ UnknownOperatorError,
45
+ )
46
+ from fluxrules.domain.models import EngineRule as _EngineRule
47
+ from fluxrules.domain.models import EvaluationResult, Ruleset
48
+ from fluxrules.domain.rule_builder import ConditionBuilder, RuleBuilder
49
+ from fluxrules.domain.unified_rule import Rule
50
+ from fluxrules.engine import (
51
+ BaseEngine,
52
+ get_available_engines,
53
+ get_engine,
54
+ register_engine,
55
+ unregister_engine,
56
+ )
57
+ from fluxrules.engine.configuration import EngineConfig
58
+ from fluxrules.engine.operator_registry import OperatorRegistry, register_operator
59
+ from fluxrules.engine.phreak import PhreakEngine
60
+ from fluxrules.initialization import (
61
+ initialize_fluxrules,
62
+ initialize_persistence,
63
+ )
64
+ from fluxrules.ports.persistence import RulePersistencePort
65
+ from fluxrules.ports.validation import ValidationIssue, Validator
66
+ from fluxrules.services.audit_service import AuditService
67
+ from fluxrules.services.evaluation_service import EvaluationService
68
+ from fluxrules.services.persistence_service import PersistenceService
69
+ from fluxrules.services.rule_service import RuleService
70
+ from fluxrules.services.validation_service import ValidationService
71
+ from fluxrules.utils.id_generators import IDStrategy, RuleIDGenerator
72
+ from fluxrules.version import __version__
73
+
74
+ _DEFAULT_SERVICE = RuleService.create()
75
+
76
+
77
+ #: What :func:`evaluate` and :func:`validate` accept: one rule, several rules,
78
+ #: or an already-built :class:`Ruleset`.
79
+ Rules = Ruleset | Rule | Iterable[Rule]
80
+
81
+
82
+ def _coerce_facts(facts: object) -> dict[str, Any]:
83
+ """Return *facts* as a plain dict, accepting Pydantic models too."""
84
+ if isinstance(facts, dict):
85
+ return facts
86
+ if hasattr(facts, "model_dump"): # Pydantic v2
87
+ return facts.model_dump()
88
+ if hasattr(facts, "dict"): # Pydantic v1
89
+ return facts.dict()
90
+ raise TypeError(
91
+ f"facts must be a dict or a Pydantic model, got {type(facts).__name__}. "
92
+ "Convert your object to a dict first."
93
+ )
94
+
95
+
96
+ def _as_ruleset(rules: Rules, *, group: str = "default") -> Ruleset:
97
+ """Normalise whatever the caller passed into a :class:`Ruleset`.
98
+
99
+ A ``Ruleset`` passes through. A single :class:`Rule` or an iterable of them
100
+ is converted here, so callers never have to reach for the internal
101
+ ``to_engine_rule()`` adapter just to evaluate a list of rules.
102
+ """
103
+ if isinstance(rules, Ruleset):
104
+ return rules
105
+ if isinstance(rules, Rule):
106
+ rules = [rules]
107
+
108
+ engine_rules: list[_EngineRule] = []
109
+ for rule in rules:
110
+ if isinstance(rule, Rule):
111
+ engine_rules.append(rule.to_engine_rule())
112
+ elif isinstance(rule, _EngineRule):
113
+ engine_rules.append(rule)
114
+ else:
115
+ raise TypeError(
116
+ "Expected a Rule, a Ruleset, or an iterable of Rules; got "
117
+ f"{type(rule).__name__}. Build rules with fluxrules.Rule(...) "
118
+ "or fluxrules.RuleBuilder."
119
+ )
120
+ return Ruleset(group=group, rules=tuple(engine_rules))
121
+
122
+
123
+ def evaluate(rules: Rules, facts: object) -> EvaluationResult:
124
+ """Evaluate rules against a fact map with default in-memory services.
125
+
126
+ *rules* may be a single :class:`Rule`, any iterable of them, or a
127
+ :class:`Ruleset`::
128
+
129
+ rule = Rule(name="adult", condition_dsl={...}, action="allow")
130
+ result = evaluate(rule, {"age": 30})
131
+ result.fired_rules # the rules that matched
132
+
133
+ *facts* may be a plain ``dict``, a Pydantic model, or any object with a
134
+ ``model_dump()`` / ``dict()`` method.
135
+
136
+ Returns:
137
+ An :class:`EvaluationResult` whose ``fired_rules`` lists what matched.
138
+ """
139
+ result = _DEFAULT_SERVICE.evaluate_inline(_as_ruleset(rules), _coerce_facts(facts))
140
+ _DEFAULT_SERVICE.execution_store.save(result)
141
+ return result
142
+
143
+
144
+ def validate(rules: Rules) -> list[str]:
145
+ """Validate rules and return human-readable issues.
146
+
147
+ Accepts the same shapes as :func:`evaluate`. An empty list means valid.
148
+ """
149
+ return ValidationService().validate_ruleset(_as_ruleset(rules))
150
+
151
+
152
+ def explain(execution_id: str) -> dict[str, object]:
153
+ """Return a transport-safe explanation payload for callers."""
154
+ execution_result = _DEFAULT_SERVICE.explain(execution_id)
155
+ return {
156
+ "execution_id": execution_result.execution_id,
157
+ "fired_rules": execution_result.fired_rules,
158
+ "actions": execution_result.actions,
159
+ "trace": execution_result.trace,
160
+ }
161
+
162
+
163
+ __all__ = [
164
+ "AuditService",
165
+ "BaseEngine",
166
+ "ConditionBuilder",
167
+ "ConfigValidationError",
168
+ "CorruptStoredRuleError",
169
+ # Error taxonomy (see fluxrules.domain.errors). Every one derives from
170
+ # FluxRulesError, so `except FluxRulesError` catches the whole family.
171
+ "CyclicDependencyError",
172
+ "DSLValidationError",
173
+ "DeploymentConfig",
174
+ "DeploymentType",
175
+ "EmptyRuleLogicError",
176
+ "EngineConfig",
177
+ "EvaluationResult",
178
+ "EvaluationService",
179
+ "FluxRulesError",
180
+ "IDStrategy",
181
+ "InvalidRuleError",
182
+ "LossyConditionRebuildError",
183
+ "LossyConditionViewWarning",
184
+ "OperatorRegistry",
185
+ "PersistenceService",
186
+ "PhreakEngine",
187
+ "Rule",
188
+ "RuleBuilder",
189
+ "RuleIDGenerator",
190
+ "RulePersistencePort",
191
+ "RuleService",
192
+ "Rules",
193
+ "Ruleset",
194
+ "UnknownOperatorError",
195
+ "ValidationIssue",
196
+ "ValidationService",
197
+ "Validator",
198
+ "__version__",
199
+ "evaluate",
200
+ "explain",
201
+ "get_available_engines",
202
+ "get_engine",
203
+ "initialize_fluxrules",
204
+ "initialize_persistence",
205
+ "register_engine",
206
+ "register_operator",
207
+ "unregister_engine",
208
+ "validate",
209
+ ]
@@ -0,0 +1,3 @@
1
+ """Adapter implementations (concrete strategy classes)."""
2
+
3
+ __all__ = []
@@ -0,0 +1,100 @@
1
+ """In-memory rule cache adapter for testing and embedded mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from fluxrules.ports.rule_cache import RuleCachePort
9
+
10
+
11
+ class InMemoryRuleCacheAdapter(RuleCachePort):
12
+ """In-memory LRU-style cache for compiled rule networks.
13
+
14
+ Supports TTL-based expiration. Suitable for testing and single-process mode.
15
+ """
16
+
17
+ def __init__(self, max_size: int = 1000) -> None:
18
+ self._cache: dict[str, tuple[Any, float]] = {} # key -> (value, expire_time)
19
+ self._max_size = max_size
20
+ self._stats = {"hits": 0, "misses": 0}
21
+ self._closed = False
22
+
23
+ def _make_key(self, ruleset_id: int, version: int) -> str:
24
+ return f"{ruleset_id}:{version}"
25
+
26
+ def get(self, ruleset_id: int, version: int) -> Any | None:
27
+ """Get compiled rule network from cache."""
28
+ if self._closed:
29
+ return None
30
+
31
+ key = self._make_key(ruleset_id, version)
32
+ entry = self._cache.get(key)
33
+
34
+ if entry is None:
35
+ self._stats["misses"] += 1
36
+ return None
37
+
38
+ value, expire_time = entry
39
+ if time.time() > expire_time:
40
+ del self._cache[key]
41
+ self._stats["misses"] += 1
42
+ return None
43
+
44
+ self._stats["hits"] += 1
45
+ return value
46
+
47
+ def set(
48
+ self,
49
+ ruleset_id: int,
50
+ version: int,
51
+ network: Any,
52
+ ttl_seconds: int = 3600,
53
+ ) -> None:
54
+ """Store compiled rule network with TTL."""
55
+ if self._closed:
56
+ return
57
+
58
+ # Evict oldest if at capacity
59
+ if len(self._cache) >= self._max_size:
60
+ oldest_key = next(iter(self._cache))
61
+ del self._cache[oldest_key]
62
+
63
+ key = self._make_key(ruleset_id, version)
64
+ self._cache[key] = (network, time.time() + ttl_seconds)
65
+
66
+ def invalidate(self, ruleset_id: int) -> None:
67
+ """Invalidate all versions of a ruleset."""
68
+ prefix = f"{ruleset_id}:"
69
+ keys_to_delete = [k for k in self._cache if k.startswith(prefix)]
70
+ for key in keys_to_delete:
71
+ del self._cache[key]
72
+
73
+ def get_stats(self) -> dict[str, Any]:
74
+ """Return cache statistics."""
75
+ total = self._stats["hits"] + self._stats["misses"]
76
+ hit_rate = self._stats["hits"] / total if total > 0 else 0.0
77
+ return {
78
+ "hits": self._stats["hits"],
79
+ "misses": self._stats["misses"],
80
+ "hit_rate": hit_rate,
81
+ "size": len(self._cache),
82
+ "max_size": self._max_size,
83
+ }
84
+
85
+ def health_check(self) -> tuple[bool, str]:
86
+ """Always healthy for in-memory."""
87
+ if self._closed:
88
+ return False, "Cache adapter is closed"
89
+ return True, "In-memory cache is healthy"
90
+
91
+ def close(self) -> None:
92
+ """Mark as closed."""
93
+ self._closed = True
94
+ self._cache.clear()
95
+
96
+ def reset(self) -> None:
97
+ """Reset all state (for testing)."""
98
+ self._cache.clear()
99
+ self._stats = {"hits": 0, "misses": 0}
100
+ self._closed = False
@@ -0,0 +1,5 @@
1
+ """Loaders package - YAML and other rule source adapters."""
2
+
3
+ from fluxrules.adapters.loaders.yaml_loader import load_rules_from_yaml
4
+
5
+ __all__ = ["load_rules_from_yaml"]
@@ -0,0 +1,47 @@
1
+ """YAML rule loader - load rules from a YAML file.
2
+
3
+ YAML schema: a list of rule mappings (or a single mapping) with the same
4
+ schema as the JSON/dict format used by ``RuleEngine.add_rules()``.
5
+
6
+ Requires: ``pip install pyyaml``
7
+
8
+ Example YAML::
9
+
10
+ - id: 1
11
+ name: high_value
12
+ priority: 10
13
+ action: flag_for_review
14
+ enabled: true
15
+ condition_dsl:
16
+ type: condition
17
+ field: amount
18
+ op: ">"
19
+ value: 1000
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+
27
+ def load_rules_from_yaml(path: str) -> list[dict[str, Any]]:
28
+ """Load rules from a YAML file.
29
+
30
+ Args:
31
+ path: Path to the YAML file.
32
+
33
+ Returns:
34
+ A list of rule dicts ready to pass to ``RuleEngine.add_rules()``.
35
+
36
+ Raises:
37
+ ImportError: When ``pyyaml`` is not installed.
38
+ """
39
+ try:
40
+ import yaml # type: ignore[import]
41
+ except ImportError as exc:
42
+ raise ImportError(
43
+ "pyyaml is required for YAML rule loading. Install it with: pip install pyyaml"
44
+ ) from exc
45
+ with open(path) as f:
46
+ rules = yaml.safe_load(f)
47
+ return rules if isinstance(rules, list) else [rules]
@@ -0,0 +1,3 @@
1
+ """Observability adapter implementations."""
2
+
3
+ __all__ = ["NoopTracer"]
@@ -0,0 +1,27 @@
1
+ """No-operation tracer for when observability is not needed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fluxrules.ports.observability import TracerPort
8
+
9
+
10
+ class NoopTracer(TracerPort):
11
+ """A no-op tracer that discards all tracing data."""
12
+
13
+ def on_evaluation_start(self, ruleset_id: int | str) -> None:
14
+ """Called at the start of ruleset evaluation (no-op)."""
15
+
16
+ def on_evaluation_end(self, ruleset_id: int | str, matched_count: int) -> None:
17
+ """Called at the end of ruleset evaluation (no-op)."""
18
+
19
+ def start_span(self, name: str, attributes: dict[str, Any] | None = None) -> Any:
20
+ """Start a span (no-op)."""
21
+ return None
22
+
23
+ def end_span(self, span: Any) -> None:
24
+ """End a span (no-op)."""
25
+
26
+ def record_event(self, span: Any, name: str, attributes: dict[str, Any] | None = None) -> None:
27
+ """Record an event (no-op)."""
@@ -0,0 +1,7 @@
1
+ """SQLAlchemy persistence adapter - isolates ORM imports from core."""
2
+
3
+ from fluxrules.adapters.persistence.sqlalchemy_adapter import (
4
+ SQLAlchemyPersistenceAdapter,
5
+ )
6
+
7
+ __all__ = ["SQLAlchemyPersistenceAdapter"]
@@ -0,0 +1,168 @@
1
+ """SQLAlchemy-backed persistence adapter.
2
+
3
+ Isolates all ORM imports (fluxrules.api.models.*) to this adapter layer,
4
+ keeping the core and services layers free of database coupling.
5
+
6
+ Supports two construction modes:
7
+
8
+ 1. **Lazy session binding** (preferred for DI / ServiceFactory)::
9
+
10
+ adapter = SQLAlchemyPersistenceAdapter(
11
+ session_provider=lambda: SessionLocal(),
12
+ )
13
+
14
+ The *session_provider* callable is invoked each time a database
15
+ session is needed, enabling request-scoped session management in
16
+ web frameworks like FastAPI.
17
+
18
+ 2. **Direct session** (legacy / simple scripts)::
19
+
20
+ adapter = SQLAlchemyPersistenceAdapter(db=my_session)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from collections.abc import Callable
27
+ from datetime import datetime, timezone
28
+
29
+ from sqlalchemy.orm import Session
30
+
31
+ from fluxrules.domain.models import EngineRule, Ruleset
32
+ from fluxrules.persistence.mappers import domain_rule_to_orm, orm_rule_to_domain
33
+ from fluxrules.ports.persistence import RulePersistencePort
34
+
35
+ logger = logging.getLogger("fluxrules.adapters.persistence")
36
+
37
+
38
+ class SQLAlchemyPersistenceAdapter(RulePersistencePort):
39
+ """Concrete persistence implementation using SQLAlchemy ORM.
40
+
41
+ All ORM model imports are isolated here, so the core engine
42
+ and services never depend on the API/database layer directly.
43
+
44
+ Args:
45
+ db: A pre-existing SQLAlchemy session (legacy mode).
46
+ session_provider: A callable returning a SQLAlchemy ``Session``.
47
+ When provided, the adapter calls it each time it needs a
48
+ session. This enables **lazy session binding** - the
49
+ recommended pattern for ServiceFactory / DI usage.
50
+
51
+ Raises:
52
+ ValueError: If neither *db* nor *session_provider* is given.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ db: Session | None = None,
58
+ *,
59
+ session_provider: Callable[[], Session] | None = None,
60
+ ) -> None:
61
+ if db is None and session_provider is None:
62
+ raise ValueError(
63
+ "SQLAlchemyPersistenceAdapter requires either 'db' "
64
+ "(a Session) or 'session_provider' (a callable)"
65
+ )
66
+ self._db = db
67
+ self._session_provider = session_provider
68
+
69
+ @property
70
+ def db(self) -> Session:
71
+ """Return the active database session.
72
+
73
+ When a *session_provider* was supplied at construction time the
74
+ provider is called each time this property is accessed, enabling
75
+ request-scoped or lazy session semantics.
76
+ """
77
+ if self._session_provider is not None:
78
+ return self._session_provider()
79
+ assert self._db is not None
80
+ return self._db
81
+
82
+ # ── Write operations ─────────────────────────────────────────────────────
83
+
84
+ def save_rule(self, rule: EngineRule, group: str = "", created_by: int | None = None) -> int:
85
+ orm_rule = domain_rule_to_orm(rule, group=group, created_by=created_by)
86
+ self.db.add(orm_rule)
87
+ self.db.commit()
88
+ self.db.refresh(orm_rule)
89
+ return orm_rule.id
90
+
91
+ def save_ruleset(self, ruleset: Ruleset, created_by: int | None = None) -> list[int]:
92
+ ids: list[int] = []
93
+ for rule in ruleset.rules:
94
+ rid = self.save_rule(rule, group=ruleset.group, created_by=created_by)
95
+ ids.append(rid)
96
+ return ids
97
+
98
+ def update_rule(self, rule_id: int, **kwargs) -> EngineRule | None:
99
+ from fluxrules.api.models.rule import Rule as OrmRule
100
+
101
+ orm_rule = self.db.query(OrmRule).filter(OrmRule.id == rule_id).first()
102
+ if orm_rule is None:
103
+ return None
104
+
105
+ for key, value in kwargs.items():
106
+ if key == "conditions" and isinstance(value, (list, tuple)):
107
+ # Store a DSL tree, not a list of condition dicts: the column
108
+ # holds exactly one shape.
109
+ from fluxrules.domain.dsl.evaluator import conditions_to_dsl
110
+
111
+ orm_rule.condition_dsl = conditions_to_dsl(value)
112
+ elif key == "condition_dsl":
113
+ orm_rule.condition_dsl = value
114
+ elif key == "actions" and isinstance(value, (list, tuple)):
115
+ orm_rule.action = "\n".join(value)
116
+ elif hasattr(orm_rule, key):
117
+ setattr(orm_rule, key, value)
118
+
119
+ orm_rule.updated_at = datetime.now(timezone.utc)
120
+ self.db.commit()
121
+ self.db.refresh(orm_rule)
122
+ return orm_rule_to_domain(orm_rule)
123
+
124
+ def delete_rule(self, rule_id: int) -> bool:
125
+ from fluxrules.api.models.rule import Rule as OrmRule
126
+
127
+ orm_rule = self.db.query(OrmRule).filter(OrmRule.id == rule_id).first()
128
+ if orm_rule is None:
129
+ return False
130
+ self.db.delete(orm_rule)
131
+ self.db.commit()
132
+ return True
133
+
134
+ # ── Read operations ──────────────────────────────────────────────────────
135
+
136
+ def load_rule(self, rule_id: int) -> EngineRule | None:
137
+ from fluxrules.api.models.rule import Rule as OrmRule
138
+
139
+ orm_rule = self.db.query(OrmRule).filter(OrmRule.id == rule_id).first()
140
+ if orm_rule is None:
141
+ return None
142
+ return orm_rule_to_domain(orm_rule)
143
+
144
+ def load_all_rules(self) -> list[EngineRule]:
145
+ from fluxrules.api.models.rule import Rule as OrmRule
146
+
147
+ orm_rules = self.db.query(OrmRule).order_by(OrmRule.priority.desc()).all()
148
+ return [orm_rule_to_domain(r) for r in orm_rules]
149
+
150
+ def load_ruleset(self, group: str) -> Ruleset:
151
+ from fluxrules.api.models.rule import Rule as OrmRule
152
+
153
+ orm_rules = (
154
+ self.db.query(OrmRule)
155
+ .filter(OrmRule.group == group)
156
+ .order_by(OrmRule.priority.desc())
157
+ .all()
158
+ )
159
+ rules = [orm_rule_to_domain(r) for r in orm_rules]
160
+ return Ruleset(group=group, rules=tuple(rules))
161
+
162
+ def load_all_rulesets(self) -> dict[str, Ruleset]:
163
+ all_rules = self.load_all_rules()
164
+ groups: dict[str, list[EngineRule]] = {}
165
+ for r in all_rules:
166
+ g = r.group or ""
167
+ groups.setdefault(g, []).append(r)
168
+ return {g: Ruleset(group=g, rules=tuple(rs)) for g, rs in groups.items()}
@@ -0,0 +1,101 @@
1
+ """In-memory queue adapter for testing and embedded mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections import defaultdict
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from fluxrules.ports.queue import QueuePort
11
+
12
+
13
+ class InMemoryQueueAdapter(QueuePort):
14
+ """In-memory queue implementation for testing and single-process mode.
15
+
16
+ Thread-safe for single-process usage. Does not persist across restarts.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ self._topics: dict[str, list[dict[str, Any]]] = defaultdict(list)
21
+ self._offsets: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
22
+ self._closed = False
23
+
24
+ def enqueue(
25
+ self,
26
+ topic: str,
27
+ partition_key: str,
28
+ message: dict[str, Any],
29
+ priority: int = 0,
30
+ ) -> str:
31
+ """Enqueue a message to a topic."""
32
+ if self._closed:
33
+ raise RuntimeError("Queue adapter is closed")
34
+
35
+ message_id = str(uuid.uuid4())
36
+ enriched = {
37
+ "id": message_id,
38
+ "type": message.get("type", "unknown"),
39
+ "payload": message.get("payload", {}),
40
+ "source": message.get("source", "api"),
41
+ "timestamp": datetime.now(timezone.utc).isoformat(),
42
+ "priority": priority,
43
+ "partition_key": partition_key,
44
+ "retry_count": 0,
45
+ }
46
+ self._topics[topic].append(enriched)
47
+ return message_id
48
+
49
+ def dequeue(
50
+ self,
51
+ topic: str,
52
+ consumer_group: str,
53
+ timeout_ms: int = 1000,
54
+ ) -> list[dict[str, Any]]:
55
+ """Dequeue messages from a topic."""
56
+ if self._closed:
57
+ raise RuntimeError("Queue adapter is closed")
58
+
59
+ current_offset = self._offsets[topic][consumer_group]
60
+ messages = self._topics[topic][current_offset:]
61
+
62
+ result = []
63
+ for i, msg in enumerate(messages):
64
+ result.append(
65
+ {
66
+ "payload": msg,
67
+ "topic": topic,
68
+ "partition": 0,
69
+ "offset": current_offset + i,
70
+ "timestamp": msg["timestamp"],
71
+ }
72
+ )
73
+
74
+ return result
75
+
76
+ def commit_offset(self, topic: str, partition: int, offset: int, consumer_group: str) -> None:
77
+ """Commit offset after successful processing."""
78
+ self._offsets[topic][consumer_group] = offset + 1
79
+
80
+ def peek(self, topic: str, partition: int, offset: int) -> dict[str, Any] | None:
81
+ """Peek at a message without consuming."""
82
+ messages = self._topics.get(topic, [])
83
+ if 0 <= offset < len(messages):
84
+ return messages[offset]
85
+ return None
86
+
87
+ def health_check(self) -> tuple[bool, str]:
88
+ """Always healthy for in-memory."""
89
+ if self._closed:
90
+ return False, "Queue adapter is closed"
91
+ return True, "In-memory queue is healthy"
92
+
93
+ def close(self) -> None:
94
+ """Mark as closed."""
95
+ self._closed = True
96
+
97
+ def reset(self) -> None:
98
+ """Reset all state (for testing)."""
99
+ self._topics.clear()
100
+ self._offsets.clear()
101
+ self._closed = False
@@ -0,0 +1,15 @@
1
+ """Repository adapter implementations."""
2
+
3
+ from fluxrules.adapters.repository.database import (
4
+ DatabaseRulesetRepository,
5
+ )
6
+ from fluxrules.adapters.repository.in_memory import (
7
+ InMemoryExecutionStore,
8
+ InMemoryRulesetRepository,
9
+ )
10
+
11
+ __all__ = [
12
+ "DatabaseRulesetRepository",
13
+ "InMemoryExecutionStore",
14
+ "InMemoryRulesetRepository",
15
+ ]