diff --git a/README.md b/README.md
index be24aa0..ccf7efc 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://openjdk.org)
[](https://spring.io/projects/spring-boot)
-> YAML DSL-based rule engine with AST processing, Python compilation, audit trails, and reactive APIs for dynamic business rule evaluation.
+> YAML DSL-based rule engine with AST processing, audit trails, and reactive APIs for dynamic business rule evaluation.
---
@@ -16,6 +16,8 @@
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
+- [Custom Functions](#custom-functions)
+- [Error Contract](#error-contract)
- [Configuration](#configuration)
- [Documentation](#documentation)
- [Contributing](#contributing)
@@ -23,27 +25,36 @@
## Overview
-Firefly Framework Rule Engine provides a powerful business rule evaluation system based on a custom YAML DSL. Rules are defined in YAML format and parsed into an Abstract Syntax Tree (AST) for efficient evaluation. The engine supports complex conditions, arithmetic operations, loop constructs, function calls, REST API calls, and JsonPath expressions.
+Firefly Framework Rule Engine provides a business rule evaluation system based on a custom YAML DSL. Rules are defined in YAML and parsed into an Abstract Syntax Tree (AST) for efficient evaluation. The engine supports rich conditions, arithmetic, loop constructs, function calls, REST API calls, JsonPath expressions, **decision tables (DMN-style)**, **rule composition (`invoke_rule`)**, and a pluggable function-registry extension point.
-The project is structured as a multi-module build with five sub-modules: interfaces (DTOs and validation), models (database entities and repositories), core (DSL parser, evaluator, and services), SDK (client library), and web (REST controllers). It features Python code generation for rule compilation, batch evaluation, audit trail tracking, and caching for rule definitions.
+The project is structured as a multi-module Maven build with five sub-modules: `interfaces` (DTOs and validation), `models` (R2DBC entities and repositories), `core` (DSL parser, evaluator, services, function registry), `web` (Spring WebFlux REST controllers), and `sdk` (generated client). The engine provides batch evaluation, audit-trail tracking, and a dedicated cache layer.
-The YAML DSL supports variables, conditionals, loops (while, do-while, for-each), list operations, circuit breaker actions, and nested rule invocations, making it suitable for complex business rule scenarios such as credit scoring, eligibility checks, and pricing calculations.
+The YAML DSL supports input/computed/constant variable tiers (with declared **input defaults**), 30+ comparison operators, logical composition (and/or/not), loops (`forEach`, `while`, `do-while`), inline conditionals (`if/then/else`), 80+ built-in functions (math, finance, date, string, list, validation, REST, JSON, type-conversion, statistics, advanced math, hashing, logging), **per-rule timeout**, **drools-style sub-rule priority**, and circuit-breaker actions for early termination.
## Features
-- Custom YAML DSL with lexer, parser, and AST-based evaluation
-- Condition types: comparison, logical (AND/OR), expression-based
-- Action types: set, calculate, conditional, loops (while, do-while, for-each), function calls
-- Expression types: arithmetic, binary, unary, literals, variables, JsonPath, REST calls
-- Python code generation and compilation for rule optimization
-- Batch rule evaluation for processing multiple inputs
-- Rule definition CRUD with database persistence via R2DBC
-- Constants management for shared rule variables
-- Audit trail tracking for all rule evaluations
-- YAML DSL validation with syntax and naming convention checks
-- Caching for rule definitions and evaluation results
-- REST API controllers for evaluation, definitions, constants, audit, and validation
-- Reactive APIs using Project Reactor
+- Custom YAML DSL with dedicated lexer + recursive-descent parser + visitor-based evaluator
+- 30+ comparison operators including `between`, `in_list`, `matches`, `is_email`, `is_credit_score`, etc.
+- Logical composition (`and`, `or`, `not`) with short-circuit evaluation
+- Action types: `set`, `calculate`, `run`, `call`, arithmetic (`add`/`subtract`/`multiply`/`divide`), list ops (`append`/`prepend`/`remove`), `forEach`, `while`, `do-while`, `if/then/else`, `circuit_breaker`
+- 80+ built-in functions covering math, advanced math (`exp`, `ln`, `sin`, `cos`, `tan`, `atan2`), hashing (`hash`), string, date, list, statistical (`median`, `stddev`, `variance`, `percentile`), financial, validation, REST, JSON path, type conversion, and structured logging
+- **Decision Tables (DMN-style)**: tabular `decision_table:` block with `FIRST`, `COLLECT`, `ANY`, and `UNIQUE` hit policies; `=` prefix marks expression outputs
+- **Rule Composition**: `invoke_rule(code, "key1", v1, "key2", v2, ...)` calls a stored rule by code and returns its outputs as a Map for chaining
+- **Sub-rule Priority (drools-style salience)**: `priority: N` on each sub-rule; higher priority evaluates first, stable on ties
+- **Input Defaults**: declare `default:` per input in the `inputs:` block; caller-omitted variables are filled in automatically
+- **Per-Rule Timeout**: declare `timeout: 5s` (or `500ms`/raw ms) to bound wall-clock runtime via Reactor `Mono.timeout()`
+- Pre-parse YAML lint catches the most common authoring trap (unquoted `:` inside action lines) before SnakeYAML throws a confusing error
+- Strict naming validation: input variables (`camelCase`), constants (`UPPER_CASE`), and computed variables (`snake_case`) are validation errors when violated
+- Pluggable function registry (`CustomFunctionRegistry`) — register your own `RuleFunction` beans and call them from rules
+- Constants tier loaded from the database with TTL caching; auto-detection of `UPPER_CASE` references in the AST
+- Reactive evaluation API on Project Reactor; synchronous visitor scheduled on `Schedulers.boundedElastic()` so it never blocks the Netty event loop
+- Batch evaluation with bounded concurrency and per-request timeouts
+- Rule-definition CRUD with R2DBC persistence and a cached AST
+- Audit-trail tracking for every evaluation (correlated, PII-masked)
+- YAML DSL validation: syntax, naming-convention, dependency, function-existence
+- RFC 7807 problem-detail error responses; correlation IDs propagated across the chain
+- Fail-loud error contract: malformed rules, unknown functions, type-coercion errors, and bad regexes surface as `success=false` with precise diagnostics rather than silently flipping to the else branch
+- Spring WebFlux controllers; OpenAPI 3 / Swagger UI
## Requirements
@@ -61,70 +72,196 @@ The rule engine is a multi-module project. Include the modules you need:
org.fireflyframeworkfireflyframework-rule-engine-core
- 26.02.07
+ 26.05.07org.fireflyframeworkfireflyframework-rule-engine-interfaces
- 26.02.07
+ 26.05.07org.fireflyframeworkfireflyframework-rule-engine-sdk
- 26.02.07
+ 26.05.07
```
## Quick Start
+### Naming conventions
+The DSL is strict about variable naming so the engine can resolve names without ambiguity:
+
+| Tier | Convention | Example |
+| ---------------- | ------------ | ------------------------------------ |
+| Input variables | `camelCase` | `creditScore`, `annualIncome` |
+| Computed values | `snake_case` | `debt_to_income`, `risk_tier` |
+| Database constants | `UPPER_CASE` | `MIN_CREDIT_SCORE`, `MAX_DTI` |
+
+### Example rule (YAML DSL)
+
```yaml
-# Example rule definition (YAML DSL)
-name: credit-score-check
-version: 1
+name: "Credit Eligibility"
+description: "Two-stage credit and income gate"
+version: "1.0.0"
+
inputs:
- - name: creditScore
- type: number
- - name: income
- type: number
-
-rules:
- - name: evaluate-eligibility
- conditions:
- - field: creditScore
- operator: ">="
- value: 700
- - field: income
- operator: ">="
- value: 50000
- actions:
- - set:
- eligible: true
- tier: "premium"
- else:
- - set:
- eligible: false
- tier: "standard"
+ creditScore: "number"
+ annualIncome: "number"
+
+constants:
+ - code: MIN_CREDIT_SCORE
+ defaultValue: 700
+ - code: MIN_INCOME
+ defaultValue: 50000
+
+when:
+ - creditScore at_least MIN_CREDIT_SCORE
+ - annualIncome at_least MIN_INCOME
+
+then:
+ - calculate debt_to_income as 0 # placeholder; real rules would compute this
+ - set tier to if_else(creditScore at_least 800, "PRIME", "PREFERRED")
+ - set eligible to true
+
+else:
+ - set tier to "STANDARD"
+ - set eligible to false
+
+output:
+ eligible: eligible
+ tier: tier
```
+### Decision Table example (DMN-style)
+
+```yaml
+name: "Auto Insurance Premium Table"
+
+inputs:
+ creditScore: number
+ age: number
+
+decision_table:
+ inputs: [creditScore, age]
+ outputs: [tier, rate]
+ hit_policy: FIRST
+ rules:
+ - when:
+ - creditScore at_least 750
+ - age between 25 and 65
+ then:
+ tier: "PRIME"
+ rate: 3.0
+ - when:
+ - creditScore at_least 650
+ then:
+ tier: "PREFERRED"
+ rate: 5.0
+ - otherwise: true
+ then:
+ tier: "STANDARD"
+ rate: 9.0
+
+output:
+ tier: tier
+ rate: rate
+```
+
+### Rule Composition example (`invoke_rule`)
+
+```yaml
+name: "Underwriting Orchestrator"
+
+inputs:
+ creditScore: number
+ annualIncome: number
+ existingDebt: number
+
+then:
+ - run scoring as invoke_rule("composite_underwriting",
+ "creditScore", creditScore,
+ "annualIncome", annualIncome,
+ "existingDebt", existingDebt)
+ - set tier to scoring.tier
+ - set approved to scoring.approved
+
+output:
+ tier: tier
+ approved: approved
+```
+
+### Calling the engine from Java
+
```java
@Service
public class CreditCheckService {
private final RulesEvaluationService evaluationService;
- public Mono evaluate(Map inputs) {
- RulesEvaluationRequestDTO request = new RulesEvaluationRequestDTO();
- request.setRuleCode("credit-score-check");
- request.setInputs(inputs);
- return evaluationService.evaluate(request);
+ public Mono evaluate(Map inputData) {
+ RuleEvaluationByCodeRequestDTO request = RuleEvaluationByCodeRequestDTO.builder()
+ .ruleDefinitionCode("credit_eligibility_v1")
+ .inputData(inputData)
+ .build();
+ return evaluationService.evaluateRuleByCode(request, exchange);
}
}
```
+The same evaluation is also reachable via REST: `POST /api/v1/rules/evaluate/direct` (Base64-encoded YAML), `/evaluate/plain` (raw YAML), or `/evaluate/by-code` (stored rule code).
+
+## Custom Functions
+
+You can extend the DSL with your own functions without modifying the engine:
+
+```java
+@Configuration
+class MyRulesConfig {
+
+ @Bean
+ CommandLineRunner registerCustomFunctions(CustomFunctionRegistry registry) {
+ return args -> {
+ registry.register("regional_risk", a ->
+ Set.of("CA", "NY").contains(a[0]) ? 10 : 0);
+ registry.register("fraud_score", a ->
+ fraudService.score(String.valueOf(a[0])));
+ };
+ }
+}
+```
+
+Then in a rule:
+
+```yaml
+then:
+ - run risk_bump as regional_risk(region)
+ - run fraud as fraud_score(applicantId)
+```
+
+Custom functions are checked **before** the built-in catalog (so they can shadow a built-in if you choose). Names are matched case-insensitively. The same function is callable from both expression contexts (`run`/`calculate`) and `call`-action contexts.
+
+## Error Contract
+
+The engine fails loud by design — errors are never silently swallowed:
+
+| Situation | Result |
+| -------------------------------------------- | --------------------------------------------------------------------------- |
+| Unknown function name | `IllegalArgumentException` -> rule reports `success=false` with the name |
+| Non-numeric string in arithmetic | `IllegalArgumentException` naming the operand |
+| Bad regex pattern in `matches` | `IllegalArgumentException` naming the pattern |
+| Missing bean property | `IllegalArgumentException` naming the class + property |
+| Unknown `is_valid` validation type | `IllegalArgumentException` listing supported types |
+| Action throws during execution | Rule reports `success=false` with action index + debug string + cause |
+| Condition throws during evaluation | Rule reports `success=false` (does not silently flip to the else branch) |
+| `circuit_breaker` action triggered | Rule reports `success=true` with `circuitBreakerTriggered=true` + message |
+| REST function HTTP failure | Returns a structured `{success:false, error:true, message}` map (chain-friendly) |
+| `getCachedAST` / cache read failure | Treated as cache miss (parse path); logged via `doOnError` |
+| `set computed_var to null` (e.g., `json_get` missing path) | Variable is stored as null; the rule succeeds |
+
## Configuration
```yaml
@@ -135,8 +272,6 @@ firefly:
ttl: 10m
audit:
enabled: true
- python-compilation:
- enabled: false
spring:
r2dbc:
@@ -150,12 +285,12 @@ Additional documentation is available in the [docs/](docs/) directory:
- [Quick Start Guide](docs/quick-start-guide.md)
- [Architecture](docs/architecture.md)
- [Yaml Dsl Reference](docs/yaml-dsl-reference.md)
+- [Migration Guide](docs/migration-guide.md) -- mapping from Drools / Easy Rules / hand-rolled if/else services
- [Api Documentation](docs/api-documentation.md)
- [Developer Guide](docs/developer-guide.md)
- [Configuration Examples](docs/configuration-examples.md)
- [Common Patterns Guide](docs/common-patterns-guide.md)
- [Inputs Section Guide](docs/inputs-section-guide.md)
-- [Python Compilation Complete Guide](docs/python-compilation-complete-guide.md)
- [Performance Optimization](docs/performance-optimization.md)
- [Governance Guidelines](docs/governance-guidelines.md)
- [B2B Credit Scoring Tutorial](docs/b2b-credit-scoring-tutorial.md)
diff --git a/docs/architecture.md b/docs/architecture.md
index 6adaa22..ea32ad9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1141,7 +1141,6 @@ GET /api/v1/audit/trails/entity/{entityId}?limit=10
- YAML DSL validation during parsing
- DTO validation with Bean Validation
- SQL injection prevention with parameterized queries
-- Variable name and comment sanitization in Python code generation to prevent code injection
- Regex pattern caching with LRU eviction (64 entries) to prevent ReDoS via unbounded compilation
### 2. SSRF Protection
@@ -1151,34 +1150,48 @@ The `RestCallServiceImpl` includes comprehensive URL validation before executing
- **Cloud metadata blocking**: Requests to `169.254.169.254` (cloud metadata endpoint) are blocked
- **Host validation**: URLs without a valid host are rejected
-### 3. Cryptographic Security (Python Runtime)
-- **No hardcoded keys**: Encryption functions require explicit key configuration; no fallback to default keys
-- **Strong key derivation**: PBKDF2 with random salt (`os.urandom(16)`) and 480,000 iterations
-- **No weak algorithms**: MD5 hashing is explicitly rejected; only SHA-256 and SHA-512 are supported
-- **Timing-safe comparison**: Hash verification uses `hmac.compare_digest()` to prevent timing attacks
-
-### 4. Safe Code Evaluation
-- **No unsafe reflection**: `ExpressionEvaluator.getPropertyValue()` uses public getter methods only; `field.setAccessible(true)` is not used
-- **Division/modulo by zero**: `ExpressionEvaluator` and `ActionExecutor` throw `ArithmeticException` instead of returning null or silently failing
-- **Short-circuit evaluation**: AND/OR operators use lazy evaluation to prevent unnecessary side effects
-- **Thread-safe code generation**: `PythonCodeGenerator` uses `ThreadLocal` for mutable state to ensure safe concurrent compilation
-
-### 5. Error Handling
-- Graceful degradation for missing constants
-- Circuit breaker pattern for external dependencies
-- Comprehensive logging for audit trails
-- Null-safe audit context extraction (defaults to "system" when no web exchange is available)
-
-### 6. Cache Integrity
+### 3. Safe Code Evaluation
+- **No unsafe reflection**: `ExpressionEvaluator.getPropertyValue()` uses public getter methods only (`getX` / `isX`); `field.setAccessible(true)` is never called. A missing getter throws `IllegalArgumentException` with the class + property name rather than silently returning null.
+- **Division/modulo by zero**: `ExpressionEvaluator` and `ActionExecutor` throw `ArithmeticException` rather than returning null or silently failing.
+- **Short-circuit evaluation**: AND/OR operators use lazy evaluation to prevent unnecessary side effects.
+
+### 4. Error Handling (Fail-Loud Contract)
+
+The engine is intentionally non-silent. Errors propagate to the rule's `success=false`
+result with a precise diagnostic message rather than being swallowed and producing a
+plausible-but-wrong output.
+
+| Source of failure | Behaviour |
+| ------------------------------------------ | ------------------------------------------------------------------------ |
+| Unknown function name | `IllegalArgumentException` -> `success=false` |
+| Non-numeric string in arithmetic | `IllegalArgumentException` naming the operand |
+| Bad regex pattern in `matches` | `IllegalArgumentException` naming the pattern |
+| Missing bean property | `IllegalArgumentException` naming class + property |
+| Unknown `is_valid` validation type | `IllegalArgumentException` listing supported types |
+| Unknown `dateadd`/`datediff` unit | `IllegalArgumentException` listing supported units |
+| Action throws mid-execution | Rule reports `success=false` with action index + debug string + cause |
+| Condition throws mid-evaluation | Rule reports `success=false` (does not silently flip to the else branch) |
+| `circuit_breaker` action triggered | Rule reports `success=true` with `circuitBreakerTriggered=true` |
+| Required constant missing in database | `success=false` listing the missing codes |
+| REST function HTTP failure | Structured error map `{success:false, error:true, message:...}` (intentional chain-friendly contract; rules can branch on `response.success`) |
+| Cache read failure | Treated as cache miss; logged via `doOnError` |
+
+Surrounding mechanisms:
+- Graceful degradation for missing **optional** constants (with explicit `defaultValue`).
+- Circuit breaker pattern for external dependencies.
+- Comprehensive audit trail (every evaluation logged with correlation ID).
+- Null-safe audit context extraction (defaults to "system" when no web exchange is available).
+
+### 5. Cache Integrity
- Cache invalidation on all CRUD operations (create, update, delete) for rule definitions
- Reactive cache access uses `subscribeOn(Schedulers.boundedElastic())` to prevent blocking the Netty event loop
-### 7. Database Security
+### 6. Database Security
- R2DBC with prepared statements
- Connection encryption with SSL
- Database user with minimal privileges
-### 8. API Security (Future)
+### 7. API Security (Future)
- JWT token authentication
- Rate limiting per client
- Request/response encryption
diff --git a/docs/b2b-credit-scoring-tutorial.md b/docs/b2b-credit-scoring-tutorial.md
index cb2e664..e2ffe66 100644
--- a/docs/b2b-credit-scoring-tutorial.md
+++ b/docs/b2b-credit-scoring-tutorial.md
@@ -35,6 +35,7 @@ By the end of this tutorial, you'll understand:
Every Firefly rule follows a consistent structure. Let's start with the basic template:
+
```yaml
# Required metadata
name: "Rule Name"
@@ -236,6 +237,7 @@ constants:
Our B2B credit scoring will use multiple sequential rules to build a comprehensive assessment. This approach mirrors real-world credit evaluation processes where different aspects are analyzed in stages.
+
```yaml
# Multi-stage evaluation using sequential rules
rules:
@@ -276,8 +278,8 @@ rules:
)
# Calculate data quality indicators
- - calculate revenue_variance as abs(annualRevenue - verifiedAnnualRevenue) / verifiedAnnualRevenue
- - calculate deposit_variance as abs(avgMonthlyDeposits - monthlyRevenue) / monthlyRevenue
+ - run revenue_variance as abs(annualRevenue - verifiedAnnualRevenue) / verifiedAnnualRevenue
+ - run deposit_variance as abs(avgMonthlyDeposits - monthlyRevenue) / monthlyRevenue
# Overall data completeness and quality check
- set data_validation_complete to (
@@ -451,8 +453,11 @@ rules:
- if NOT meets_financial_requirements then set rejection_reason to "Debt service capacity insufficient"
- if NOT meets_business_requirements then set rejection_reason to "Business does not meet minimum operating requirements"
- # Generate recommendation summary
- - calculate recommendation_summary as "Credit Score: " + final_credit_score + ", Risk Level: " + risk_level + ", DTI: " + (debt_to_income_ratio * 100) + "%"
+ # Generate recommendation summary. Uses `run` because the expression mixes strings
+ # with numbers (string concatenation via `+`). Wrap in YAML single-quotes because
+ # the message contains `:` followed by a space, which YAML would otherwise treat
+ # as a key/value separator.
+ - 'run recommendation_summary as "Credit Score - " + tostring(final_credit_score) + ", Risk - " + risk_level + ", DTI - " + tostring(debt_to_income_ratio * 100) + "%"'
- set final_decision_complete to true
diff --git a/docs/common-patterns-guide.md b/docs/common-patterns-guide.md
index 7c88c80..0fca4e3 100644
--- a/docs/common-patterns-guide.md
+++ b/docs/common-patterns-guide.md
@@ -122,19 +122,21 @@ then:
- set errors to []
- set valid to true
- # Individual field validation
+ # Individual field validation (each branch is a separate action so the YAML
+ # never has a colon-followed-by-space inside an unquoted action string).
- if not email is_email then append "Invalid email format" to errors
- if not phone is_phone then append "Invalid phone number" to errors
- if not ssn is_ssn then append "Invalid SSN format" to errors
- if not birthDate is_date then append "Invalid birth date" to errors
- # Update validity based on errors
- - calculate error_count as size(errors)
+ # Tally errors. `size()` is a function call, so use `run` (not `calculate`).
+ - run error_count as size(errors)
- if error_count greater_than 0 then set valid to false
else:
- set valid to false
- - set errors to ["Missing required fields: email, phone"]
+ # Wrap the action in YAML single-quotes because the message contains a `:`.
+ - 'set errors to ["Missing required fields - email, phone"]'
output:
valid: boolean
@@ -435,7 +437,7 @@ then:
- if annualIncome at_least 60000 and annualIncome less_than 100000 then set incomeComponent to 20
- if annualIncome at_least 40000 and annualIncome less_than 60000 then set incomeComponent to 15
- if annualIncome less_than 40000 then set incomeComponent to 5
-
+
# Debt ratio component (20% weight)
- calculate debt_ratio as existingDebt / annualIncome
- if debt_ratio at_most 0.2 then set debtComponent to 20
@@ -449,7 +451,7 @@ then:
- if employmentYears at_least 1 and employmentYears less_than 2 then set employmentComponent to 5
- if employmentYears less_than 1 then set employmentComponent to 2
- # Calculate final score
+ # Aggregate score (pure arithmetic, so `calculate` is fine here)
- calculate risk_score as creditComponent + incomeComponent + debtComponent + employmentComponent
# Determine risk level
@@ -458,11 +460,12 @@ then:
- if risk_score at_least 40 and risk_score less_than 60 then set risk_level to "HIGH"
- if risk_score less_than 40 then set risk_level to "VERY_HIGH"
- # Document contributing factors
- - calculate credit_factor as "Credit Score: " + tostring(creditScore) + " (Weight: " + tostring(creditComponent) + ")"
- - calculate income_factor as "Annual Income: " + tostring(annualIncome) + " (Weight: " + tostring(incomeComponent) + ")"
- - calculate debt_factor as "Debt Ratio: " + tostring(debt_ratio) + " (Weight: " + tostring(debtComponent) + ")"
- - calculate employment_factor as "Employment Years: " + tostring(employmentYears) + " (Weight: " + tostring(employmentComponent) + ")"
+ # Document contributing factors. These build strings via `tostring()` (a function
+ # call) and contain colons, so they use `run` and are wrapped in YAML quotes.
+ - 'run credit_factor as "Credit Score - " + tostring(creditScore) + " (Weight - " + tostring(creditComponent) + ")"'
+ - 'run income_factor as "Annual Income - " + tostring(annualIncome) + " (Weight - " + tostring(incomeComponent) + ")"'
+ - 'run debt_factor as "Debt Ratio - " + tostring(debt_ratio) + " (Weight - " + tostring(debtComponent) + ")"'
+ - 'run employment_factor as "Employment Years - " + tostring(employmentYears) + " (Weight - " + tostring(employmentComponent) + ")"'
- append credit_factor to factors
- append income_factor to factors
@@ -550,7 +553,7 @@ rules:
- set stage to "COMPLETED"
- set final_decision_value to decision
- - calculate processed_at as now()
+ - run processed_at as now()
- set processing_complete to true
else:
- set decision to "REJECTED"
@@ -630,7 +633,7 @@ then:
# Store metadata
- set enrichment_sources to sources
- set enrichment_quality to data_quality
- - calculate enrichment_timestamp as now()
+ - run enrichment_timestamp as now()
- set enrichment_customer_id to customerId
else:
diff --git a/docs/developer-guide.md b/docs/developer-guide.md
index 8e82c5f..c0637ef 100644
--- a/docs/developer-guide.md
+++ b/docs/developer-guide.md
@@ -207,34 +207,36 @@ org.fireflyframework.rules.core.dsl.ast/
│ └── BaseParser.java # Common parsing utilities
├── expression/ # Expression AST nodes
│ ├── Expression.java # Base expression class
-│ ├── BinaryExpression.java # Binary operations (+, -, *, /, etc.)
+│ ├── BinaryExpression.java # Binary operations (+, -, *, /, %, **, comparisons)
│ ├── UnaryExpression.java # Unary operations (-, !, validation ops)
-│ ├── LiteralExpression.java # Literal values (numbers, strings, booleans)
-│ ├── VariableExpression.java # Variable references
+│ ├── LiteralExpression.java # Literal values (numbers, strings, booleans, arrays)
+│ ├── VariableExpression.java # Variable references with property/index access
│ ├── FunctionCallExpression.java # Function calls with parameters
-│ ├── ArithmeticExpression.java # Complex arithmetic expressions
-│ ├── JsonPathExpression.java # JSON path queries
-│ ├── RestCallExpression.java # REST API calls
│ ├── BinaryOperator.java # Binary operator enumeration
│ ├── UnaryOperator.java # Unary operator enumeration
│ └── ExpressionType.java # Expression type enumeration
├── condition/ # Condition AST nodes
│ ├── Condition.java # Base condition class
-│ ├── ComparisonCondition.java # Comparison operations (>, <, ==, etc.)
+│ ├── ComparisonCondition.java # Comparison operations (>, <, ==, between, in_list, etc.)
│ ├── LogicalCondition.java # Logical operations (AND, OR, NOT)
│ ├── ExpressionCondition.java # Expression-based conditions
│ └── ComparisonOperator.java # Comparison operator enumeration
├── action/ # Action AST nodes
│ ├── Action.java # Base action class
-│ ├── SetAction.java # Variable assignment (set var to value)
-│ ├── CalculateAction.java # Arithmetic calculations
-│ ├── AssignmentAction.java # Assignment operations (=, +=, etc.)
-│ ├── FunctionCallAction.java # Function execution
-│ ├── ConditionalAction.java # If-then-else actions
-│ ├── ArithmeticAction.java # Arithmetic actions
-│ ├── ListAction.java # List operations (append, prepend, etc.)
-│ ├── CircuitBreakerAction.java # Execution control
-│ └── AssignmentOperator.java # Assignment operator enumeration
+│ ├── SetAction.java # Variable assignment (`set var to value`)
+│ ├── CalculateAction.java # Pure-math arithmetic (`calculate var as expr`)
+│ ├── RunAction.java # Function-call assignment (`run var as fn(...)`)
+│ ├── FunctionCallAction.java # Standalone function execution (`call fn with [...]`)
+│ ├── ConditionalAction.java # Inline if-then-else as an action
+│ ├── ArithmeticAction.java # `add`/`subtract`/`multiply`/`divide` X to/from/by var
+│ ├── ListAction.java # List operations (append, prepend, remove)
+│ ├── CircuitBreakerAction.java # Early termination with a structured message
+│ ├── ForEachAction.java # `forEach item in items: ...`
+│ ├── WhileAction.java # `while condition: ...`
+│ └── DoWhileAction.java # `do: ... while condition`
+├── function/ # Extension point for user-defined functions
+│ ├── RuleFunction.java # Functional interface: `Object apply(Object[] args)`
+│ └── CustomFunctionRegistry.java # @Component holding registered functions
├── visitor/ # Visitor pattern implementations
│ ├── EvaluationContext.java # Execution context and state
│ ├── ExpressionEvaluator.java # Expression evaluation visitor
@@ -663,64 +665,31 @@ public abstract class ASTNode {
```
ASTNode (abstract base)
├── Expression (abstract)
-│ ├── LiteralExpression
-│ │ ├── NumberLiteral (integers, decimals)
-│ │ ├── StringLiteral (quoted strings)
-│ │ ├── BooleanLiteral (true/false)
-│ │ └── NullLiteral (null values)
-│ ├── VariableExpression (variable references)
-│ ├── BinaryExpression
-│ │ ├── ArithmeticExpression (+, -, *, /, %, **)
-│ │ ├── ComparisonExpression (>, <, ==, !=, >=, <=)
-│ │ ├── LogicalExpression (AND, OR)
-│ │ ├── StringExpression (contains, starts_with, ends_with)
-│ │ └── ListExpression (in, not_in)
-│ ├── UnaryExpression
-│ │ ├── ArithmeticUnary (-, +)
-│ │ ├── LogicalUnary (NOT, !)
-│ │ └── ValidationUnary (is_positive, is_email, is_phone, etc.)
-│ ├── FunctionCallExpression
-│ │ ├── MathFunctions (abs, round, ceil, floor, etc.)
-│ │ ├── StringFunctions (length, substring, upper, lower, etc.)
-│ │ ├── DateFunctions (now, date_add, date_diff, etc.)
-│ │ └── CustomFunctions (user-defined functions)
-│ ├── ArithmeticExpression (complex multi-operand arithmetic)
-│ ├── JsonPathExpression (JSON path queries)
-│ └── RestCallExpression (REST API calls)
+│ ├── LiteralExpression # numbers, strings, booleans, null, array literals
+│ ├── VariableExpression # variable refs with optional property path / index access
+│ ├── BinaryExpression # +, -, *, /, %, **, comparisons, and/or, contains, starts_with, etc.
+│ ├── UnaryExpression # -, +, NOT, EXISTS, IS_NULL, IS_EMAIL, IS_POSITIVE, etc.
+│ └── FunctionCallExpression # math, string, date, list, financial, validation, REST, JSON funcs
├── Condition (abstract)
-│ ├── ComparisonCondition
-│ │ ├── SimpleComparison (var > value)
-│ │ ├── BetweenCondition (var between min and max)
-│ │ ├── InCondition (var in [list])
-│ │ └── ValidationCondition (var is_positive)
-│ ├── LogicalCondition
-│ │ ├── AndCondition (condition1 AND condition2)
-│ │ ├── OrCondition (condition1 OR condition2)
-│ │ └── NotCondition (NOT condition)
-│ └── ExpressionCondition (expression-based conditions)
+│ ├── ComparisonCondition # `>=`, `at_least`, `between ... and ...`, `in_list [...]`, `is_email`, etc.
+│ ├── LogicalCondition # AND / OR / NOT composition
+│ └── ExpressionCondition # wraps any boolean-valued Expression
└── Action (abstract)
- ├── SetAction (set variable to value)
- ├── CalculateAction (calculate variable as expression)
- ├── AssignmentAction
- │ ├── SimpleAssignment (var = value)
- │ ├── AddAssignment (var += value)
- │ ├── SubtractAssignment (var -= value)
- │ ├── MultiplyAssignment (var *= value)
- │ └── DivideAssignment (var /= value)
- ├── FunctionCallAction (call function with parameters)
- ├── ConditionalAction
- │ ├── IfAction (if condition then actions)
- │ ├── IfElseAction (if condition then actions else actions)
- │ └── SwitchAction (switch-case logic)
- ├── ArithmeticAction (arithmetic operations as actions)
- ├── ListAction
- │ ├── AppendAction (append to list)
- │ ├── PrependAction (prepend to list)
- │ ├── RemoveAction (remove from list)
- │ └── ClearAction (clear list)
- └── CircuitBreakerAction (execution control and error handling)
+ ├── SetAction # `set var to value`
+ ├── CalculateAction # `calculate var as `
+ ├── RunAction # `run var as `
+ ├── FunctionCallAction # `call fn with [args]`
+ ├── ConditionalAction # `if cond then actions [else actions]`
+ ├── ArithmeticAction # `add X to var`, `subtract X from var`, `multiply var by X`, `divide var by X`
+ ├── ListAction # `append X to list`, `prepend X to list`, `remove X from list`
+ ├── ForEachAction # `forEach item[, index] in items: actions`
+ ├── WhileAction # `while cond: actions`
+ ├── DoWhileAction # `do: actions while cond`
+ └── CircuitBreakerAction # `circuit_breaker "MESSAGE"` -- early termination
```
+> **Note:** The DSL was simplified in 2026-05 to remove orphan AST classes that were never produced by the parser. The compound-assignment family (`+=`, `-=`, etc.) and the multi-operand `ArithmeticExpression` n-ary form are no longer present. Use `add`/`subtract`/`multiply`/`divide` arithmetic actions or `calculate`/`run` with `+`/`-`/`*`/`/` for the same outcomes.
+
### 🔢 **Expression Nodes: Representing Computable Values**
**What is an Expression?**
@@ -1316,11 +1285,9 @@ public class ActionParser extends BaseParser {
return parseConditionalAction();
}
- // Handle arithmetic actions (var += value)
- if (check(TokenType.IDENTIFIER) && checkNext(TokenType.ASSIGN, TokenType.PLUS_ASSIGN,
- TokenType.MINUS_ASSIGN, TokenType.MULTIPLY_ASSIGN)) {
- return parseAssignmentAction();
- }
+ // Arithmetic actions are emitted by `add`/`subtract`/`multiply`/`divide` keywords
+ // (parseArithmeticAction), not by `=` / `+=` operators -- the latter are not part
+ // of the action DSL.
throw error("Expected action statement");
}
@@ -1400,29 +1367,29 @@ The `ASTVisitor` interface is the heart of the visitor pattern implementation:
public interface ASTVisitor {
// Expression visitors - handle value computation
- T visitBinaryExpression(BinaryExpression node); // a + b, a > b, etc.
- T visitUnaryExpression(UnaryExpression node); // -a, !a, is_positive(a)
- T visitVariableExpression(VariableExpression node); // creditScore, income
- T visitLiteralExpression(LiteralExpression node); // 42, "hello", true
- T visitFunctionCallExpression(FunctionCallExpression node); // max(a, b)
- T visitArithmeticExpression(ArithmeticExpression node); // Complex arithmetic
- T visitJsonPathExpression(JsonPathExpression node); // $.user.name
- T visitRestCallExpression(RestCallExpression node); // REST API calls
+ T visitBinaryExpression(BinaryExpression node); // a + b, a > b, a and b, etc.
+ T visitUnaryExpression(UnaryExpression node); // -a, !a, is_positive(a)
+ T visitVariableExpression(VariableExpression node); // creditScore, user.profile.name
+ T visitLiteralExpression(LiteralExpression node); // 42, "hello", true, [1,2,3]
+ T visitFunctionCallExpression(FunctionCallExpression node); // max(a, b), if_else(...), coalesce(...), rest_get(...), json_get(...)
// Condition visitors - handle boolean logic
- T visitComparisonCondition(ComparisonCondition node); // a > b
- T visitLogicalCondition(LogicalCondition node); // a AND b
- T visitExpressionCondition(ExpressionCondition node); // Expression as condition
+ T visitComparisonCondition(ComparisonCondition node); // a > b, a between x and y, etc.
+ T visitLogicalCondition(LogicalCondition node); // a AND b, a OR b, NOT a
+ T visitExpressionCondition(ExpressionCondition node); // any Expression as a condition
// Action visitors - handle state changes
T visitSetAction(SetAction node); // set var to value
- T visitCalculateAction(CalculateAction node); // calculate var as expr
- T visitAssignmentAction(AssignmentAction node); // var = value, var += value
- T visitFunctionCallAction(FunctionCallAction node); // call function()
- T visitConditionalAction(ConditionalAction node); // if-then-else
- T visitArithmeticAction(ArithmeticAction node); // Arithmetic as action
- T visitListAction(ListAction node); // List operations
- T visitCircuitBreakerAction(CircuitBreakerAction node); // Error handling
+ T visitCalculateAction(CalculateAction node); // calculate var as expr (pure-math only)
+ T visitRunAction(RunAction node); // run var as fn(...) / json_get(...) / rest_*(...)
+ T visitFunctionCallAction(FunctionCallAction node); // call fn with [args]
+ T visitConditionalAction(ConditionalAction node); // if cond then ... else ...
+ T visitArithmeticAction(ArithmeticAction node); // add/subtract/multiply/divide ... to/from/by var
+ T visitListAction(ListAction node); // append/prepend/remove ... to/from list
+ T visitCircuitBreakerAction(CircuitBreakerAction node); // circuit_breaker "MESSAGE"
+ T visitForEachAction(ForEachAction node); // forEach item[, index] in items: actions
+ T visitWhileAction(WhileAction node); // while cond: actions
+ T visitDoWhileAction(DoWhileAction node); // do: actions while cond
}
```
@@ -1578,44 +1545,33 @@ public class ActionExecutor implements ASTVisitor {
}
@Override
- public Void visitAssignmentAction(AssignmentAction node) {
- // Step 1: Evaluate the new value
+ public Void visitArithmeticAction(ArithmeticAction node) {
+ // Equivalent of an "in-place" compound assignment, expressed via dedicated keywords
+ // in the DSL (`add X to var`, `subtract X from var`, etc.). Both operands must be
+ // numeric; non-numeric operands raise IllegalArgumentException so authoring bugs
+ // surface immediately rather than silently no-op'ing.
Object value = node.getValue().accept(expressionEvaluator);
+ Object current = context.getVariable(node.getVariableName());
- // Step 2: Apply the assignment operator
- switch (node.getOperator()) {
- case ASSIGN -> {
- // Simple assignment: var = value
- context.setComputedVariable(node.getVariableName(), value);
- }
- case ADD_ASSIGN -> {
- // Addition assignment: var += value
- Object currentValue = context.getVariable(node.getVariableName());
- if (currentValue instanceof Number && value instanceof Number) {
- // Numeric addition
- BigDecimal current = toBigDecimal(currentValue);
- BigDecimal addValue = toBigDecimal(value);
- context.setComputedVariable(node.getVariableName(), current.add(addValue));
- } else {
- // String concatenation
- context.setComputedVariable(node.getVariableName(),
- currentValue.toString() + value.toString());
- }
- }
- case SUBTRACT_ASSIGN -> {
- // Subtraction assignment: var -= value
- Object currentValue = context.getVariable(node.getVariableName());
- if (currentValue instanceof Number && value instanceof Number) {
- BigDecimal current = toBigDecimal(currentValue);
- BigDecimal subValue = toBigDecimal(value);
- context.setComputedVariable(node.getVariableName(), current.subtract(subValue));
- } else {
- throw new ASTException("Cannot subtract non-numeric values");
- }
- }
- // ... other assignment operators
+ if (!(current instanceof Number) || !(value instanceof Number)) {
+ throw new IllegalArgumentException(
+ "Arithmetic action requires numeric operands");
}
+ BigDecimal currentNum = toBigDecimal(current);
+ BigDecimal valueNum = toBigDecimal(value);
+ BigDecimal result = switch (node.getOperation()) {
+ case ADD -> currentNum.add(valueNum);
+ case SUBTRACT -> currentNum.subtract(valueNum);
+ case MULTIPLY -> currentNum.multiply(valueNum);
+ case DIVIDE -> {
+ if (valueNum.signum() == 0) {
+ throw new ArithmeticException("Division by zero in arithmetic action");
+ }
+ yield currentNum.divide(valueNum, 10, RoundingMode.HALF_UP);
+ }
+ };
+ context.setComputedVariable(node.getVariableName(), result);
return null;
}
diff --git a/docs/governance-guidelines.md b/docs/governance-guidelines.md
index 9a7e783..2ff1684 100644
--- a/docs/governance-guidelines.md
+++ b/docs/governance-guidelines.md
@@ -243,12 +243,14 @@ else:
```
**For Intermediate and Advanced:**
+
+Use the `circuit_breaker` *action* inside `then:` to short-circuit a rule when a
+downstream dependency or risk signal trips:
+
+
```yaml
-# Add circuit breakers for external dependencies
-circuit_breaker:
- enabled: true
- failure_threshold: 3
- timeout_duration: "10s"
+then:
+ - if downstream_failure_count at_least 3 then circuit_breaker "DOWNSTREAM_UNAVAILABLE"
```
---
diff --git a/docs/migration-guide.md b/docs/migration-guide.md
new file mode 100644
index 0000000..867bb07
--- /dev/null
+++ b/docs/migration-guide.md
@@ -0,0 +1,257 @@
+# Migration Guide
+
+A practical mapping from common rule-engine concepts to the Firefly Rule Engine DSL. The goal is to give you the **shortest path from a rule you've written in another engine to its Firefly equivalent**, and to make the trade-offs explicit so you know whether this engine is the right fit.
+
+## Table of Contents
+
+- [Where Firefly sits in the landscape](#where-firefly-sits-in-the-landscape)
+- [Migrating from Drools (DRL)](#migrating-from-drools-drl)
+- [Migrating from Easy Rules](#migrating-from-easy-rules)
+- [Migrating from a hand-rolled if/else service](#migrating-from-a-hand-rolled-ifelse-service)
+- [When Firefly is the wrong tool](#when-firefly-is-the-wrong-tool)
+
+---
+
+## Where Firefly sits in the landscape
+
+Firefly Rule Engine is a **stateless expression-evaluation engine over a single
+input map**. Each evaluation is an independent function call: parse a YAML rule,
+feed it inputs, get outputs.
+
+This is intentionally a smaller, more focused model than full-blown rule engines:
+
+| Concept | Firefly | Drools (KIE) | Easy Rules |
+| ------------------------------------ | ---------------------- | -------------------------- | --------------------- |
+| Rule format | YAML DSL | DRL (Java-like) | Java annotations / MVEL |
+| State model | Stateless per eval | Stateful KieSession + Working Memory | Stateless `Facts` |
+| Rule chaining | Sub-rules within one eval + `invoke_rule` for cross-rule composition | Full forward-chaining inference | Priority-ordered list |
+| Rule priority / salience | Per sub-rule `priority: N` (drools-style) | `salience N` | `@Priority(N)` |
+| Multi-fact joins | Not supported | Native (LHS pattern matching) | Not directly |
+| Decision tables | DMN-style `decision_table:` block with FIRST / COLLECT / ANY / UNIQUE | Native (DRT, spreadsheets) | Not directly |
+| Per-rule timeout | Declarative `timeout: 5s` (Reactor `Mono.timeout()`) | KieSession-wide | Not directly |
+| Input defaults | Declared in `inputs:` block | Not directly | Not directly |
+| Persistent fact base | Not supported | KIE working memory | Not supported |
+| External calls (REST/JSON) in rules | Built-in (`rest_get`, `json_get`, etc.) | Plugin-based | Custom actions |
+| Custom function extensions | Spring `@Bean` registration | Imports + globals | `RuleListener` |
+| Audit trail | Built-in | Plugin | Listener interface |
+| Hot reload | DB-backed; replace rule definition | KieScanner | App reload |
+
+If your rules are **"score / decide / classify based on this one payload"**, Firefly
+is purpose-built. If your rules are **"find applications where the spouse's credit
+history triggers something on the primary applicant's file"**, you want Drools.
+
+---
+
+## Migrating from Drools (DRL)
+
+### Side-by-side: a credit-score-driven approval
+
+**Drools (DRL):**
+
+```drl
+rule "Approve high credit"
+when
+ $a : Application(creditScore >= 700, annualIncome >= 50000)
+then
+ $a.setApprovalStatus("APPROVED");
+ $a.setTier("STANDARD");
+ update($a);
+end
+
+rule "Premium tier"
+when
+ $a : Application(creditScore >= 800, annualIncome >= 100000)
+then
+ $a.setTier("PRIME");
+ update($a);
+end
+```
+
+**Firefly (YAML):**
+
+```yaml
+name: "Application Approval"
+inputs:
+ creditScore: "number"
+ annualIncome: "number"
+
+when:
+ - creditScore at_least 700
+ - annualIncome at_least 50000
+
+then:
+ - set approval_status to "APPROVED"
+ - run tier as if_else(creditScore at_least 800 and annualIncome at_least 100000, "PRIME", "STANDARD")
+
+else:
+ - set approval_status to "DECLINED"
+
+output:
+ approval_status: text
+ tier: text
+```
+
+### Conceptual mapping
+
+| Drools | Firefly |
+| ------------------------------------- | ------------------------------------------------------ |
+| `KieSession` + `insert(fact)` | Pass a `Map` of inputs |
+| `rule "X" when ... then ... end` | A `rules:` entry, or a top-level `when/then/else` |
+| LHS pattern matching `$a : Application(field op value)` | `when:` list of conditions on input variables |
+| `update($a)` | Implicit -- variables in `EvaluationContext` are mutable for the rest of this evaluation |
+| Salience / priority | Order of entries in the `rules:` list |
+| `agendaGroup`, `ruleflow-group` | Sub-rules within one evaluation; chained by output → input |
+| Forward chaining (a `then` triggers another rule's `when`) | **Not supported** -- run multiple evaluations or use sub-rules with explicit variables |
+| `accumulate` over multiple facts | **Not supported** -- pass an already-aggregated value as an input |
+| `query` / backward chaining | **Not supported** |
+| Decision tables (DRT) | Express as `if/then/else` chains or many `rules:` |
+| Globals (`global Logger logger`) | Constants (`UPPER_CASE`, loaded from DB) |
+| Imports / `function` blocks | Spring beans implementing `RuleFunction` and registered with `CustomFunctionRegistry` |
+| `kmodule.xml` | YAML rule stored as a `RuleDefinition` row in the database |
+
+### Patterns that map cleanly
+
+- **Single-fact decisions** -- direct translation.
+- **Tiered scoring** with `if/then/else` chains -- direct translation.
+- **Decision tables** -- DRL `RuleTable` blocks map to Firefly `decision_table:` with `FIRST`, `COLLECT`, `ANY`, or `UNIQUE` hit policies. See [yaml-dsl-reference.md](yaml-dsl-reference.md#decision-tables-dmn-style).
+- **Salience / priority** -- DRL `salience` corresponds to Firefly's per-sub-rule `priority: N`. Higher priority evaluates first; ties preserve declaration order.
+- **Rule composition** -- DRL `rule "X" extends "Y"` and modify-then-fire chains map to `invoke_rule("other_rule", "k1", v1, "k2", v2)` which returns the nested rule's output map.
+- **External data calls** -- Drools requires plugin work; Firefly has `rest_get`, `json_get`, etc. built-in.
+- **Per-rule budgets** -- where DRL relies on `KieSession`-level timeouts, Firefly accepts a `timeout: 5s` directive on each rule.
+
+### Patterns that don't map
+
+- **Cross-fact joins** -- no equivalent. Pre-compute the join into a single input.
+- **Truth maintenance / retraction** -- no equivalent. Re-run with new inputs.
+- **Backward chaining** -- no equivalent.
+
+---
+
+## Migrating from Easy Rules
+
+Easy Rules is closer to Firefly in spirit (stateless, single-fact). The migration is
+usually mechanical.
+
+**Easy Rules (annotated Java):**
+
+```java
+@Rule(name = "Approve high credit", priority = 1)
+public class ApproveHighCreditRule {
+
+ @Condition
+ public boolean when(@Fact("creditScore") int score,
+ @Fact("annualIncome") double income) {
+ return score >= 700 && income >= 50000;
+ }
+
+ @Action
+ public void then(Facts facts) {
+ facts.put("approvalStatus", "APPROVED");
+ }
+}
+```
+
+**Firefly (YAML):**
+
+```yaml
+name: "Approve high credit"
+inputs:
+ creditScore: "number"
+ annualIncome: "number"
+when:
+ - creditScore at_least 700
+ - annualIncome at_least 50000
+then:
+ - set approval_status to "APPROVED"
+else:
+ - set approval_status to "DECLINED"
+output:
+ approval_status: text
+```
+
+### Conceptual mapping
+
+| Easy Rules | Firefly |
+| ------------------------------------- | ------------------------------------------------------ |
+| `Facts facts = new Facts(); facts.put(...)` | `Map` input map |
+| `Rules` collection | A single multi-rule YAML or many YAML rule definitions |
+| `@Rule(priority = N)` | Order in `rules:` list |
+| `RulesEngine` (e.g., `DefaultRulesEngine`) | `ASTRulesEvaluationEngine` |
+| `RuleListener` | Built-in audit trail + `CustomFunctionRegistry` |
+| MVEL expression `creditScore > 700` | `creditScore greater_than 700` (or `creditScore > 700`) |
+| `@Action` method body | `then:` action list |
+| Composite rules | Sub-rules in the `rules:` block |
+
+### What you gain
+
+- No Java compilation step to deploy a new rule.
+- Database-backed rule definitions with hot-replace.
+- Built-in REST / JSON / cache layers.
+- Build-time validation of every rule example in your docs.
+
+### What you give up
+
+- MVEL / arbitrary Java expression power -- the Firefly DSL is intentionally narrower
+ to keep rules reviewable.
+- Direct access to your domain objects from inside a rule -- the engine sees only
+ the input map.
+
+---
+
+## Migrating from a hand-rolled if/else service
+
+This is the most common starting point. You have a Java service like:
+
+```java
+public DecisionResult decide(Customer c) {
+ DecisionResult r = new DecisionResult();
+ if (c.getCreditScore() >= 700 && c.getAnnualIncome() >= 50000) {
+ r.setApproved(true);
+ if (c.getCreditScore() >= 800) {
+ r.setTier("PRIME");
+ } else {
+ r.setTier("STANDARD");
+ }
+ } else {
+ r.setApproved(false);
+ r.setReason("Insufficient credit or income");
+ }
+ return r;
+}
+```
+
+The Firefly equivalent is the YAML rule shown in the [Drools section above](#migrating-from-drools-drl).
+The translation pattern:
+
+1. **Inputs**: every method parameter / accessed bean field → an entry in `inputs:`
+2. **Top-level `if` test**: → `when:` conditions
+3. **Inside `if` body**: → `then:` actions
+4. **Inside `else` body**: → `else:` actions
+5. **Nested `if/else`**: → `if cond then ... else ...` action, or a sub-rule
+6. **Result builder**: → `output:` mapping
+
+### Why move from hand-rolled to Firefly
+
+- Rules become editable artefacts rather than code deployments.
+- A non-engineer can read, review, and propose changes to the YAML.
+- The audit trail and validation come for free.
+
+---
+
+## When Firefly is the wrong tool
+
+Be honest about the model. Choose a different engine if you need any of:
+
+- **Inference / forward chaining**: facts in working memory triggering more rules as
+ they're derived. Use Drools.
+- **Backward chaining / goal-driven reasoning**: e.g., "find a configuration that
+ satisfies these constraints". Use Drools or a CLP solver.
+- **Decision tables in Excel format** consumed directly. Use Drools DRT, OpenL Tablets,
+ or a DMN engine.
+- **Multi-fact joins**: rules that match across pairs/groups of facts.
+- **Truth maintenance**: retracting a fact and having dependent conclusions roll back.
+- **Workflow / BPMN** orchestration with long-running state.
+
+For everything that's expressible as "given this payload, compute / decide / classify
+the outcome", Firefly's narrower model is the right tool and will be faster to onramp,
+easier to review, and harder to misuse than a full-blown rule engine.
diff --git a/docs/python-compilation-complete-guide.md b/docs/python-compilation-complete-guide.md
deleted file mode 100644
index 8b79636..0000000
--- a/docs/python-compilation-complete-guide.md
+++ /dev/null
@@ -1,641 +0,0 @@
-# Python Compilation - Complete Guide
-
-## 🐍 Overview
-
-The Firefly Framework Rule Engine supports compiling YAML DSL rules to executable Python code, enabling rule execution without the Java runtime. This comprehensive guide covers all aspects of Python compilation, from basic usage to advanced features.
-
-## 🚀 Key Features
-
-- **100% DSL Support**: All YAML DSL features are supported in Python compilation
-- **Standalone Execution**: Generated Python code runs independently without Java dependencies
-- **Database Integration**: Seamless integration with ConstantService for dynamic constants
-- **Interactive Mode**: Built-in CLI interface for testing and debugging
-- **Professional Quality**: Complete license headers, documentation, and error handling
-- **Runtime Library**: Comprehensive Python library with 103+ built-in functions
-
-## 🏗️ Architecture
-
-### Core Components
-
-1. **PythonCodeGenerator**: AST visitor that generates Python code from parsed DSL
-2. **PythonCompilationService**: Service layer managing compilation, caching, and statistics
-3. **PythonCompiledRule**: Model representing compiled Python rule with metadata
-4. **Python Runtime Library**: Complete Python library with all built-in functions
-5. **REST API**: Endpoints for compilation, cache management, and statistics
-
-### Compilation Pipeline
-
-```mermaid
-graph TD
- A[YAML DSL] --> B[AST Parser]
- B --> C[DSL Validation]
- C --> D[PythonCodeGenerator]
- D --> E[Constants Integration]
- E --> F[Python Code]
- F --> G[PythonCompiledRule]
- G --> H[Cache Storage]
- G --> I[API Response]
-```
-
-## 🎯 Quick Start
-
-### 1. Compile a Rule from YAML
-
-```bash
-curl -X POST http://localhost:8080/api/v1/python/compile \
- -H "Content-Type: text/plain" \
- -d 'name: "credit_check"
-description: "Simple credit score validation"
-version: "1.0.0"
-
-input:
- creditScore: "number"
-
-output:
- approved: "boolean"
-
-when: creditScore >= 650
-then:
- - set approved = true
-else:
- - set approved = false'
-```
-
-### 2. Compile a Rule from Database
-
-If you have rules already stored in the database, you can compile them directly:
-
-**By Rule ID:**
-```bash
-# Get the rule ID from the database first
-curl "http://localhost:8080/api/v1/rules/definitions" | jq '.content[0].id'
-
-# Compile using the ID
-curl -X POST "http://localhost:8080/api/v1/python/compile/rule/123e4567-e89b-12d3-a456-426614174000"
-```
-
-**By Rule Code:**
-```bash
-# Compile using the rule's unique code (easier to remember)
-curl -X POST "http://localhost:8080/api/v1/python/compile/rule/code/credit_scoring_v1"
-```
-
-**Advantages of Database Compilation:**
-- ✅ **No YAML needed** - Rules are already validated and stored
-- ✅ **Version control** - Compile specific versions by ID
-- ✅ **Production ready** - Use rules that passed all validations
-- ✅ **Audit trail** - Full traceability of compiled rules
-
-### 3. Install Python Runtime
-
-```bash
-# Install globally (macOS)
-cd python-runtime
-pip3 install --break-system-packages -e .
-
-# Verify installation
-python3 -c "import firefly_runtime; print('Runtime installed successfully!')"
-```
-
-### 4. Execute Generated Code
-
-```python
-# The generated Python file includes interactive execution
-python3 compiled-rule.py
-
-# Or import and use programmatically
-from compiled_rule import credit_check
-result = credit_check({'creditScore': 720})
-print(result) # {'approved': True}
-```
-
-## 📋 Generated Python Code Structure
-
-### Complete Example
-
-```python
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-#
-# Copyright 2024-2026 Firefly Software Foundation
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-# Generated by Firefly Framework Rule Engine Python Compiler
-# Made with ❤️ by Firefly Software Foundation
-# Compilation Date: 2025-09-15T13:46:07.887874+02:00
-
-from firefly_runtime import *
-
-def credit_check(context):
- """
- Rule: credit_check
- Simple credit score validation
-
- Args:
- context (dict): Execution context with input variables
-
- Returns:
- dict: Output variables
- """
-
- # Initialize constants from database or default values
- constants = {}
- constants['MIN_CREDIT_SCORE'] = 650 # Default value
-
- # Rule logic
- if context.get('creditScore', 0) >= constants['MIN_CREDIT_SCORE']:
- context['approved'] = True
- else:
- context['approved'] = False
-
- # Return output variables
- return {
- 'approved': context.get('approved')
- }
-
-if __name__ == "__main__":
- print_firefly_header("credit_check", "Simple credit score validation", "1.0.0")
-
- # Configure constants interactively if needed
- constants_need_config = []
- configure_constants_interactively(constants_need_config)
-
- # Collect input values
- context = collect_inputs({'creditScore': 'number'})
-
- # Execute rule
- print_execution_results(credit_check(context))
-
- print_firefly_footer()
-```
-
-## 🔧 Constants Integration
-
-### Database vs Default Values
-
-The compiler handles three scenarios for constants:
-
-1. **Database Override**: Constants from database override default values
-2. **Default Fallback**: Use default values when not in database
-3. **Missing Constants**: Set to `None` with warnings
-
-```python
-# Initialize constants from database or default values
-constants['EXISTING_CONSTANT'] = 999 # From database
-constants['DEFAULT_ONLY_CONSTANT'] = 500 # Default value
-constants['MISSING_CONSTANT'] = None # WARNING: Not found
-```
-
-### Interactive Configuration
-
-Generated code includes interactive constant configuration:
-
-```python
-if constants_need_config:
- print("⚠️ WARNING: The following constants are not configured:")
- for const in constants_need_config:
- print(f" - {const}")
-
- configure = input("\n🔧 Would you like to configure them now? (y/n): ").lower()
- if configure == 'y':
- for const in constants_need_config:
- value = get_user_input(f"{const}: ", "auto")
- if value is not None:
- constants[const] = value
-```
-
-## 📚 Python Runtime Library
-
-### Complete Function Coverage (103 Functions)
-
-#### Core Functions
-- `firefly_get_nested_value()`, `firefly_get_indexed_value()`
-- `firefly_is_empty()`, `firefly_is_not_empty()`
-- `firefly_size()`, `firefly_count()`, `firefly_first()`, `firefly_last()`
-- `firefly_average()`, `firefly_between()`, `firefly_exists()`
-
-#### Financial Functions
-- `firefly_calculate_loan_payment()`, `firefly_calculate_compound_interest()`
-- `firefly_calculate_credit_score()`, `firefly_debt_to_income_ratio()`
-- `firefly_credit_utilization()`, `firefly_loan_to_value()`
-- `firefly_calculate_debt_ratio()`, `firefly_calculate_ltv()`
-
-#### Validation Functions
-- `firefly_validate_ssn()`, `firefly_validate_email()`, `firefly_validate_phone()`
-- `firefly_is_positive()`, `firefly_is_negative()`, `firefly_is_zero()`
-- `firefly_is_null()`, `firefly_is_not_null()`, `firefly_is_numeric()`
-
-#### String Functions
-- `firefly_upper()`, `firefly_lower()`, `firefly_trim()`
-- `firefly_contains()`, `firefly_startswith()`, `firefly_endswith()`
-- `firefly_replace()`, `firefly_matches()`, `firefly_length()`
-
-#### Date/Time Functions
-- `firefly_now()`, `firefly_today()`, `firefly_dateadd()`, `firefly_datediff()`
-- `firefly_time_hour()`, `firefly_time_minute()`, `firefly_time_second()`
-
-#### REST Client Functions
-- `firefly_rest_get()`, `firefly_rest_post()`, `firefly_rest_put()`
-- `firefly_rest_delete()`, `firefly_rest_patch()`, `firefly_rest_call()`
-
-#### JSON Functions
-- `firefly_json_extract()`, `firefly_json_exists()`, `firefly_json_size()`
-- `firefly_json_keys()`, `firefly_json_values()`, `firefly_json_merge()`
-
-#### Security Functions
-- `firefly_encrypt()`, `firefly_decrypt()`, `firefly_hash()`
-- `firefly_mask_data()`, `firefly_generate_uuid()`
-
-#### Interactive Functions
-- `get_user_input()`, `collect_inputs()`, `configure_constants_interactively()`
-- `print_firefly_header()`, `print_execution_results()`, `print_firefly_footer()`
-
-### HTTP Best Practices
-
-The runtime includes validation for HTTP methods:
-
-```python
-# DELETE and GET requests should not have bodies
-if method == 'DELETE' and body is not None:
- warnings.warn(
- "DELETE requests should not include a request body according to HTTP standards. "
- "The body parameter will be ignored.",
- UserWarning
- )
-```
-
-## 🌐 REST API Reference
-
-### Compile Single Rule from YAML
-
-**POST** `/api/v1/python/compile`
-
-**Parameters:**
-- `ruleName` (optional): Name for the rule
-- `useCache` (default: true): Whether to use compilation cache
-
-**Request Body:** YAML DSL rule definition (text/plain)
-
-**Response:** PythonCompiledRule object
-
-### Compile Rule from Database by ID
-
-**POST** `/api/v1/python/compile/rule/{ruleId}`
-
-Compiles a rule definition stored in the database using the rule's UUID.
-
-**Path Parameters:**
-- `ruleId` (required): UUID of the rule definition in the database
-
-**Query Parameters:**
-- `useCache` (default: true): Whether to use compilation cache
-
-**Response:** PythonCompiledRule object
-
-**Example:**
-```bash
-curl -X POST "http://localhost:8080/api/v1/python/compile/rule/123e4567-e89b-12d3-a456-426614174000?useCache=true"
-```
-
-### Compile Rule from Database by Code
-
-**POST** `/api/v1/python/compile/rule/code/{ruleCode}`
-
-Compiles a rule definition stored in the database using the rule's unique code identifier.
-
-**Path Parameters:**
-- `ruleCode` (required): Unique code of the rule definition (e.g., "credit_scoring_v1")
-
-**Query Parameters:**
-- `useCache` (default: true): Whether to use compilation cache
-
-**Response:** PythonCompiledRule object
-
-**Example:**
-```bash
-curl -X POST "http://localhost:8080/api/v1/python/compile/rule/code/credit_scoring_v1?useCache=true"
-```
-
-### Batch Compile Rules
-
-**POST** `/api/v1/python/compile/batch`
-
-**Request Body:** Map of rule names to YAML DSL definitions
-
-### Cache Management
-
-- **GET** `/api/v1/python/stats` - Get compilation statistics
-- **DELETE** `/api/v1/python/cache` - Clear compilation cache
-- **POST** `/api/v1/python/cache/check` - Check if rule is cached
-- **POST** `/api/v1/python/cache/get` - Get cached compiled rule
-- **DELETE** `/api/v1/python/cache/rule?ruleName=name` - Remove specific rule from cache
-
-**Note**: The DELETE endpoint uses query parameters instead of request body to follow HTTP best practices.
-
-### API Response Format
-
-All compilation endpoints return a `PythonCompiledRule` object:
-
-```json
-{
- "ruleName": "credit_scoring_v1",
- "description": "Advanced credit scoring rule",
- "version": "1.0.0",
- "pythonCode": "#!/usr/bin/env python3\n# Generated Python code...",
- "functionName": "credit_scoring_v1",
- "inputVariables": ["creditScore", "income", "debtRatio"],
- "outputVariables": {
- "approved": "boolean",
- "maxLoanAmount": "number",
- "interestRate": "number"
- },
- "compiledAt": "2025-09-15T14:30:00Z",
- "sourceHash": "abc123def456"
-}
-```
-
-### Error Responses
-
-**404 Not Found** - Rule not found in database:
-```json
-{
- "error": "Rule definition not found",
- "message": "No rule definition found with ID: 123e4567-e89b-12d3-a456-426614174000",
- "ruleId": "123e4567-e89b-12d3-a456-426614174000"
-}
-```
-
-**400 Bad Request** - Compilation error:
-```json
-{
- "error": "Compilation failed",
- "message": "Invalid DSL syntax: Missing 'when' clause",
- "ruleId": "123e4567-e89b-12d3-a456-426614174000",
- "ruleName": "credit_scoring_v1"
-}
-```
-
-### Use Cases for Database Compilation
-
-**1. Production Deployment**
-- Compile rules that are already validated and stored in production database
-- Ensure consistency between stored rules and compiled Python code
-- Leverage existing rule management workflows
-
-**2. CI/CD Integration**
-```bash
-# Compile all active rules for deployment
-curl -X POST "http://localhost:8080/api/v1/python/compile/rule/code/credit_scoring_v1" \
- -o credit_scoring_v1.py
-
-# Deploy compiled Python files to production environment
-```
-
-**3. Rule Versioning**
-- Compile specific versions of rules by ID
-- Maintain multiple compiled versions for A/B testing
-- Rollback to previous rule versions quickly
-
-**4. Automated Workflows**
-```bash
-# Get all rule definitions and compile them
-rules=$(curl "http://localhost:8080/api/v1/rules/definitions")
-for rule_id in $(echo $rules | jq -r '.content[].id'); do
- curl -X POST "http://localhost:8080/api/v1/python/compile/rule/$rule_id" \
- -o "compiled_rule_$rule_id.py"
-done
-```
-
-## 🧪 Testing
-
-### Comprehensive Test Suite
-
-- **52 Python Runtime Tests**: All runtime functions tested
-- **17 Java Compiler Tests**: Complete compilation pipeline tested
-- **HTTP Validation Tests**: REST client best practices validated
-- **Integration Tests**: End-to-end compilation and execution
-
-### Test Results
-
-```
-Python Runtime Tests: 52 passed, 0 failed
-Java Compiler Tests: 17 passed, 0 failed
-Total Coverage: 100% DSL feature support
-```
-
-## 🎯 Advanced Features
-
-### Complex DSL Support
-
-The compiler supports all DSL features:
-
-- **Multiple Rules**: Sequential rule execution
-- **Conditional Blocks**: Nested if-then-else logic
-- **Function Calls**: All 103+ built-in functions
-- **Arithmetic Operations**: Mathematical expressions
-- **JSON Path**: Data extraction from complex objects
-- **REST Calls**: External API integration with proper HTTP validation
-
-### Performance Optimization
-
-- **AST Caching**: Parsed AST structures cached for reuse
-- **Compilation Caching**: Compiled Python code cached by DSL hash
-- **Parallel Processing**: Batch compilation uses parallel execution
-- **Memory Management**: Efficient memory usage with configurable cache sizes
-
-### Error Handling
-
-- **Validation Errors**: DSL syntax and semantic validation
-- **Compilation Errors**: Python code generation issues
-- **Runtime Errors**: Execution-time error handling with detailed diagnostics
-- **HTTP Validation**: Warnings for improper HTTP method usage
-
-## 📖 Best Practices
-
-### 1. Rule Design
-- Use descriptive rule names for better Python function names
-- Include comprehensive input/output type definitions
-- Add meaningful descriptions for generated documentation
-
-### 2. Compilation Strategy
-- **Use database compilation** for production deployments
-- **Use YAML compilation** for development and testing
-- **Enable caching** for frequently compiled rules
-- **Use rule codes** instead of IDs for better readability
-
-### 3. Database Integration
-- Store rules in database after thorough validation
-- Use semantic versioning for rule versions
-- Maintain active/inactive status for rule lifecycle management
-- Tag rules appropriately for easy discovery
-
-### 4. Performance
-- Enable caching for frequently compiled rules
-- Use batch compilation for multiple rules
-- Monitor compilation statistics for optimization
-- Compile rules during deployment, not at runtime
-
-### 5. Testing
-- Test both Java and Python execution for equivalence
-- Validate edge cases and error conditions
-- Use comprehensive test data sets
-- Test compilation from both YAML and database sources
-
-### 6. Deployment
-- Install Python runtime dependencies in target environment
-- Use virtual environments for isolation
-- Monitor Python execution performance
-- Automate rule compilation in CI/CD pipelines
-
-### 7. API Usage
-```bash
-# Good: Use rule codes for readability
-curl -X POST "/api/v1/python/compile/rule/code/credit_scoring_v1"
-
-# Good: Enable caching for production
-curl -X POST "/api/v1/python/compile/rule/code/credit_scoring_v1?useCache=true"
-
-# Good: Handle errors gracefully
-response=$(curl -s -w "%{http_code}" -X POST "/api/v1/python/compile/rule/code/invalid_rule")
-if [[ "${response: -3}" != "200" ]]; then
- echo "Compilation failed"
-fi
-```
-
-## 🔗 Integration with Existing Systems
-
-### Rule Management Workflow
-
-```mermaid
-graph TD
- A[Create Rule in UI] --> B[Store in Database]
- B --> C[Validate DSL]
- C --> D[Activate Rule]
- D --> E[Compile to Python]
- E --> F[Deploy to Production]
- F --> G[Monitor Execution]
- G --> H[Update if Needed]
- H --> B
-```
-
-### CI/CD Pipeline Integration
-
-```yaml
-# .github/workflows/compile-rules.yml
-name: Compile Rules to Python
-on:
- push:
- branches: [main]
-
-jobs:
- compile-rules:
- runs-on: ubuntu-latest
- steps:
- - name: Get Active Rules
- run: |
- curl "${{ secrets.RULE_ENGINE_URL }}/api/v1/rules/definitions?isActive=true" \
- -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" \
- -o rules.json
-
- - name: Compile Rules
- run: |
- for rule_code in $(jq -r '.content[].code' rules.json); do
- curl -X POST "${{ secrets.RULE_ENGINE_URL }}/api/v1/python/compile/rule/code/$rule_code" \
- -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" \
- -o "compiled_rules/${rule_code}.py"
- done
-
- - name: Deploy to Production
- run: |
- # Deploy compiled Python files to your production environment
- rsync -av compiled_rules/ production:/opt/rules/
-```
-
-### Microservices Architecture
-
-```python
-# rule_compiler_service.py
-import requests
-import os
-
-class RuleCompilerService:
- def __init__(self, rule_engine_url, api_token):
- self.base_url = rule_engine_url
- self.headers = {"Authorization": f"Bearer {api_token}"}
-
- def compile_rule_by_code(self, rule_code, use_cache=True):
- """Compile a rule from database by code"""
- url = f"{self.base_url}/api/v1/python/compile/rule/code/{rule_code}"
- params = {"useCache": use_cache}
-
- response = requests.post(url, headers=self.headers, params=params)
- response.raise_for_status()
-
- return response.json()
-
- def deploy_compiled_rule(self, rule_code, target_dir="/opt/rules"):
- """Compile and deploy a rule"""
- compiled_rule = self.compile_rule_by_code(rule_code)
-
- # Save compiled Python code
- file_path = os.path.join(target_dir, f"{rule_code}.py")
- with open(file_path, 'w') as f:
- f.write(compiled_rule['pythonCode'])
-
- return file_path
-
-# Usage
-compiler = RuleCompilerService("http://rule-engine:8080", "your-api-token")
-compiler.deploy_compiled_rule("credit_scoring_v1")
-```
-
-## 🔮 Migration Guide
-
-### From Java Execution to Python
-
-1. **Identify rules** to migrate using the database endpoints
-2. **Compile existing rules** using `/api/v1/python/compile/rule/code/{code}`
-3. **Test equivalence** between Java and Python execution
-4. **Deploy Python runtime** in target environment
-5. **Update applications** to use compiled Python functions
-6. **Monitor performance** and optimize as needed
-
-### From YAML to Database Compilation
-
-1. **Store rules** in database using `/api/v1/rules/definitions`
-2. **Validate rules** using the validation endpoint
-3. **Switch compilation** from YAML endpoints to database endpoints
-4. **Update CI/CD** to use rule codes instead of YAML files
-5. **Leverage rule management** features (versioning, activation, tags)
-
-### Compatibility
-
-- All DSL features are supported in Python compilation
-- Function behavior is identical between Java and Python
-- Error handling maintains the same semantics
-- Performance characteristics may vary between platforms
-- Database compilation provides additional metadata and traceability
-
-## 📞 Support
-
-For questions and support:
-
-- Check the [API Documentation](api-documentation.md)
-- Review [YAML DSL Reference](yaml-dsl-reference.md)
-- See [Developer Guide](developer-guide.md) for advanced topics
-- Report issues in the project repository
-
----
-
-*Made with ❤️ by Firefly Software Foundation*
diff --git a/docs/quick-start-guide.md b/docs/quick-start-guide.md
index 2481ddb..dcafe30 100644
--- a/docs/quick-start-guide.md
+++ b/docs/quick-start-guide.md
@@ -290,8 +290,10 @@ then:
- set current to startValue
- set count to 0
- # Always executes at least once
- - do: multiply current by 2; add 1 to count while current less_than maxValue
+ # Always executes at least once.
+ # Grammar reminder: arithmetic actions are ``,
+ # so "multiply current by 2" is written `multiply 2 by current`.
+ - do: multiply 2 by current; add 1 to count while current less_than maxValue
output:
current: number
@@ -385,6 +387,7 @@ if condition then action
```
### Basic Structure Template
+
```yaml
name: "Your Rule Name"
description: "What this rule does"
diff --git a/docs/yaml-dsl-reference.md b/docs/yaml-dsl-reference.md
index e7da86b..c084ef6 100644
--- a/docs/yaml-dsl-reference.md
+++ b/docs/yaml-dsl-reference.md
@@ -49,10 +49,49 @@ The Firefly Framework Rule Engine uses a powerful YAML-based Domain Specific Lan
### How to Use This Reference
- **🔍 Find Specific Syntax**: Use the table of contents or search for keywords
-- **📋 Copy Examples**: All code examples are tested and ready to use
+- **📋 Copy Examples**: All complete-rule code examples in this file are parsed by the `DocExamplesValidationTest` at every build -- if you see one fail in your fork, the doc is out of sync with the implementation.
- **🎯 Choose Complexity**: See [Governance Guidelines](governance-guidelines.md) for feature selection advice
- **🚀 Get Started**: Try examples from [Quick Start Guide](quick-start-guide.md) first
+### Mental Model -- What This Engine Is (and Isn't)
+
+**What it is.** A stateless **expression-evaluation engine** over a single input map.
+You hand it a parsed YAML rule and a `Map` of inputs; it returns a
+result with computed outputs, a condition outcome, and timing/audit metadata.
+
+**What it is NOT.** This is not Drools-style rule-based reasoning. There is no
+working memory, no fact base, no inference, no rule-chaining triggered by data
+changes. Each evaluation is an independent function call.
+
+| Capability | Supported by Firefly Rule Engine |
+| --------------------------------------- | ---------------------------------------------------------------------- |
+| Rule definitions in YAML | ✅ |
+| 30+ comparison and validation operators | ✅ |
+| Constants from DB (auto-detected by `UPPER_CASE`) | ✅ |
+| `forEach` / `while` / `do-while` loops | ✅ |
+| Sub-rules (`rules:` block) with shared state across rules in one eval | ✅ |
+| **Sub-rule priority** (drools-style salience via `priority: N`) | ✅ |
+| Inline conditional expression (`if_else(cond, then, else)`) | ✅ |
+| **Decision tables (DMN-style)** -- `decision_table:` block with FIRST / COLLECT / ANY / UNIQUE hit policies | ✅ |
+| **Rule composition** -- `invoke_rule(code, ...)` evaluates a stored rule and returns its outputs | ✅ |
+| **Per-rule timeout** -- `timeout: 5s` declarative budget enforced via Reactor `Mono.timeout()` | ✅ |
+| **Input defaults** -- declare `default:` per input; caller-omitted values are filled in | ✅ |
+| Custom function registry (Spring `@Component`) | ✅ |
+| REST / JSON path built-ins | ✅ |
+| Circuit breaker action (early termination) | ✅ |
+| **Rule chaining across separate evaluations** -- output of one eval automatically firing another | ❌ |
+| **Persistent working memory / fact base** like Drools `KIE` | ❌ |
+| **Inference / forward-chaining** -- deriving new facts that fire more rules | ❌ |
+| **Backward chaining** (goal-driven reasoning) | ❌ |
+| **Cross-input joins** -- finding pairs/groups of inputs that satisfy a constraint | ❌ |
+| **Short-circuit evaluation in function calls** -- `if_else(cond, X, Y)` evaluates *both* branches | ❌ |
+| **Truth maintenance** / retraction | ❌ -- variables are write-once-per-eval and never retracted |
+
+If you need any of the "❌" capabilities, this engine is the wrong tool. For those
+use cases consider Drools / OpenL Tablets / DMN engines. For everything else --
+deterministic rule evaluation over an input payload -- this engine is purpose-built
+to be smaller, faster to onramp, and clearer to reason about.
+
---
## DSL Structure Overview
@@ -79,15 +118,50 @@ constants: # Optional: Constants with defaults
- code: CONSTANT_NAME
defaultValue: value
-circuit_breaker: # Optional: Resilience configuration
- enabled: true
- failure_threshold: 5
- timeout_duration: "30s"
+timeout: 5s # Optional: per-rule wall-clock budget
+ # Accepts "5s", "500ms", or raw milliseconds.
+ # Exceeding it fails the rule with a clean message.
+```
+
+### Input Declarations with Defaults
+
+`inputs:` accepts three shapes:
+
+```yaml
+# 1. Flat list -- type defaults to Object
+inputs: [creditScore, annualIncome, age]
+
+# 2. Name -> type
+inputs:
+ creditScore: number
+ annualIncome: number
+ age: number
+
+# 3. Name -> {type, default} -- default is injected when caller omits the variable
+inputs:
+ creditScore:
+ type: number
+ default: 0
+ annualIncome:
+ type: number
+ default: 0
+ region:
+ type: string
+ default: "UNKNOWN"
```
+Caller-supplied values always override declared defaults. This makes rules safer to evaluate from partial inputs without sprinkling `coalesce(...)` calls through every action.
+
+> Note: early versions accepted a top-level `circuit_breaker:` configuration block
+> (`enabled`, `failure_threshold`, `timeout_duration`, `recovery_timeout`). It was parsed
+> but never enforced at runtime and is no longer accepted. Use the
+> `circuit_breaker "MESSAGE"` *action* (described under "Action Syntax") for controlled
+> early termination within a rule.
+
### Logic Sections (Choose One)
**Simple Syntax:**
+
```yaml
when: [conditions] # Simple condition list
then: [actions] # Actions when true
@@ -95,6 +169,7 @@ else: [actions] # Actions when false (optional)
```
**Complex Syntax:**
+
```yaml
conditions: # Structured condition blocks
if: {condition_structure}
@@ -103,19 +178,115 @@ conditions: # Structured condition blocks
```
**Multiple Rules:**
+
```yaml
rules: # Array of sub-rules
- name: "Sub-rule 1"
+ priority: 10 # Optional: higher salience runs first (default 0)
+ when: [conditions]
+ then: [actions]
+ - name: "Sub-rule 2"
+ priority: 1 # Lower priority -- evaluates after Sub-rule 1
when: [conditions]
then: [actions]
```
+Sub-rule priority is drools-style salience: higher `priority:` evaluates first; ties preserve YAML declaration order via a stable sort. Omitted priorities default to 0.
+
+**Decision Table (DMN-style):**
+
+```yaml
+decision_table:
+ inputs: [col1, col2] # Optional: input column names (for documentation)
+ outputs: [col_a, col_b] # Optional: output column names
+ hit_policy: FIRST # FIRST | COLLECT | ANY | UNIQUE (default FIRST)
+ rules:
+ - when: [predicate, predicate] # Each row is a list of `when:` predicates
+ then: { col_a: value, col_b: value }
+ - otherwise: true # Fallback row -- matches when no others did
+ then: { col_a: default_value, col_b: default_value }
+```
+
+See the [Decision Tables](#decision-tables-dmn-style) section below for the full syntax, hit-policy semantics, and the `=` prefix for expression outputs.
+
---
## Reserved Keywords
The DSL uses specific reserved keywords that have special meaning in the parser. These are organized by category for easy reference:
+### Synonyms and Canonical Forms
+
+Several keywords have multiple accepted spellings -- a deliberate flexibility so the
+DSL reads naturally in different contexts. The **canonical** form is the one we
+recommend in new code; aliases remain accepted for compatibility. All synonyms below
+are matched case-insensitively where indicated.
+
+#### Comparison operators
+
+| Canonical | Aliases (also accepted) | Notes |
+| --------- | ------------------------------- | ---------------------------------------------------------- |
+| `equals` | `==` | Prefer `equals` in prose-style conditions, `==` in expressions |
+| `not_equals` | `!=` | Same convention |
+| `greater_than` | `>` | Use the symbol in expressions, the keyword in conditions |
+| `less_than` | `<` | Same |
+| `at_least` | `greater_than_or_equal`, `>=` | `at_least` reads most naturally in financial rules |
+| `at_most` | `less_than_or_equal`, `<=` | Same |
+| `in_list` | `in` | `in_list` makes membership intent explicit |
+| `not_in_list` | `not_in` | Same |
+| `is_not_null` | (no alias) | Use this rather than `not is_null` -- the negated form is one operator |
+| `not_contains` | (no alias) | Same -- one operator, not `not contains` |
+
+#### Logical operators
+
+| Canonical | Aliases | Notes |
+| --------- | -------------------- | -------------------------------------------------------------------- |
+| `and` | `AND`, `&&` | Lower-case in YAML by convention; upper-case is also matched |
+| `or` | `OR`, `\|\|` | Same |
+| `not` | `NOT`, `!` | Unary; prefer `not x is_email` over `not_email` |
+
+#### Action verbs
+
+| Canonical | Aliases | Notes |
+| --------- | -------- | --------------------------------------------------------------------------- |
+| `forEach` | `for` | Both reach the same parser path; `forEach` reads better in mixed-case YAML |
+
+#### Built-in function aliases
+
+These are exact synonyms within the function-call layer -- pick one and stick with it
+inside a rule for readability:
+
+| Canonical | Aliases | What it does |
+| ------------ | ------------------------- | ------------------------------------------- |
+| `length` | `len` | String / list length |
+| `count` | `size` | Collection size |
+| `avg` | `average` | Mean of a list |
+| `uppercase` | `upper` | String → uppercase |
+| `lowercase` | `lower` | String → lowercase |
+| `substring` | `substr` | Extract substring |
+| `tonumber` | `number` | Coerce to number |
+| `tostring` | `string` | Coerce to string |
+| `toboolean` | `boolean` | Coerce to boolean |
+| `json_get` | `json_path` | Extract value from JSON via path |
+| `is_in_range` | `in_range` | Inclusive bounded check |
+| `if_else` | `ifelse` | Inline conditional value |
+
+#### YAML top-level keys (parser accepts both, but use the canonical form)
+
+| Canonical | Also accepted | Notes |
+| --------- | -------------- | ---------------------------------------------------------------------- |
+| `inputs` | `input` | The parser merges both into the same model field; prefer `inputs` |
+| `outputs` | `output` | Same |
+
+> **Removed in 26.05.08:** the top-level `circuit_breaker:` *configuration block* (with
+> `enabled`, `failure_threshold`, `timeout_duration`, `recovery_timeout` sub-keys) was
+> parsed but never enforced at runtime. The action-form `circuit_breaker "MESSAGE"`
+> (described in [Action Syntax](#action-syntax)) is the only circuit-breaker surface
+> and is unchanged.
+
+---
+
+
🏗️ Structural Keywords - Define the rule structure and metadata
@@ -133,7 +304,6 @@ The DSL uses specific reserved keywords that have special meaning in the parser.
| | `conditions` | ❌* | Complex condition blocks | `conditions: {if: {...}, then: {...}}` |
| | `rules` | ❌* | Multiple sequential rules | `rules: [{name: "Rule 1", when: [...]}]` |
| **Advanced Features** | `metadata` | ❌ | Additional metadata | `metadata: {tags: ["credit"], author: "Team"}` |
-| | `circuit_breaker` | ❌ | Resilience configuration | `circuit_breaker: {enabled: true}` |
*One of `when`/`then`, `conditions`, or `rules` is required for logic definition.
@@ -201,6 +371,9 @@ The DSL uses specific reserved keywords that have special meaning in the parser.
| | `ends_with` | - | String suffix | `email ends_with ".com"` |
| | `matches` | - | Regex match | `ssn matches "^\\d{3}-\\d{2}-\\d{4}$"` |
| | `not_matches` | - | Regex not match | `phone not_matches "^\\+1"` |
+| **Length** | `length_equals` | - | Length-of-string equality | `code length_equals 4` |
+| | `length_greater_than` | - | Length-of-string `>` | `password length_greater_than 7` |
+| | `length_less_than` | - | Length-of-string `<` | `nickname length_less_than 20` |
| **List** | `in_list` | `in` | List membership | `status in_list ["ACTIVE", "PENDING"]` |
| | `not_in_list` | `not_in` | List non-membership | `type not_in_list ["SUSPENDED", "CLOSED"]` |
| **Existence** | `exists` | - | Variable existence | `exists guarantorInfo` |
@@ -276,29 +449,29 @@ when:
- (monthlyIncome is_positive AND annualIncome is_positive)
```
-**NEW: Validation Operators in Expressions**
+**Validation Operators in Expressions**
-Validation operators can now be used in complex expressions, not just simple conditions:
+Validation operators can be used in any expression context, not just `when:` clauses:
+
```yaml
then:
- # Set variables using validation operators in expressions
- - set has_valid_contact to (email is_email AND phone is_phone)
- - set financial_data_complete to (
- monthlyRevenue is_positive AND
- monthlyExpenses is_positive AND
- annualIncome is_not_null
- )
-
- # Calculate boolean results with validation operators
- - calculate data_quality_score as (
- (customerName is_not_empty ? 25 : 0) +
- (email is_email ? 25 : 0) +
- (phone is_phone ? 25 : 0) +
- (ssn is_ssn ? 25 : 0)
- )
+ # Validation operators inside `set`-to-boolean expressions
+ - set has_valid_contact to (email is_email and phone is_phone)
+ - set financial_data_complete to (monthlyRevenue is_positive and monthlyExpenses is_positive and annualIncome is_not_null)
+
+ # Score each field independently with the inline `if_else` function, then sum
+ - run name_score as if_else(customerName is_not_empty, 25, 0)
+ - run email_score as if_else(email is_email, 25, 0)
+ - run phone_score as if_else(phone is_phone, 25, 0)
+ - run ssn_score as if_else(ssn is_ssn, 25, 0)
+ - calculate data_quality_score as name_score + email_score + phone_score + ssn_score
```
+> **Note:** The engine does not have a C-style ternary `? :` operator; use the
+> `if_else(condition, then_value, else_value)` built-in function instead. Both arguments
+> are evaluated eagerly (no short-circuit).
+
@@ -312,18 +485,23 @@ then:
| | `/` | `/` | Division | `expression / expression` | `monthlyDebt / annualIncome` |
| | `%` | `%` | Modulo (remainder) | `expression % expression` | `amount % 100` |
| | `**` | `**` | Power/Exponentiation | `base ** exponent` | `(1 + rate) ** years` |
-| **Arithmetic Actions** | `add` | - | Add to variable | `add value to variable` | `add 10 to base_score` |
-| | `subtract` | - | Subtract from variable | `subtract value from variable` | `subtract penalty from total_score` |
-| | `multiply` | - | Multiply variable | `multiply variable by value` | `multiply risk_factor by 1.5` |
-| | `divide` | - | Divide variable | `divide variable by value` | `divide monthly_payment by 2` |
-| **Helper Keywords** | `to` | - | Assignment target | `set variable to value` | `set approval_status to "APPROVED"` |
-| | `as` | - | Calculation target | `calculate variable as expression` | `calculate debt_ratio as monthlyDebt / annualIncome` |
-| | `with` | - | Function parameters | `call function with [args]` | `call log with ["Message", "INFO"]` |
-| | `from` | - | Subtraction source | `subtract value from variable` | `subtract penalty from total_score` |
-| | `by` | - | Factor for multiply/divide | `multiply/divide variable by value` | `multiply risk_factor by 1.5` |
-| | `and` | - | Range separator | `value between min and max` | `age between 18 and 65` |
+| **Arithmetic Actions** | `add` | - | Add value to variable | `add VALUE to VARIABLE` | `add 10 to base_score` |
+| | `subtract` | - | Subtract value from variable | `subtract VALUE from VARIABLE` | `subtract penalty from total_score` |
+| | `multiply` | - | Multiply target variable by factor | `multiply VALUE by VARIABLE` | `multiply 1.5 by risk_factor` |
+| | `divide` | - | Divide target variable by divisor | `divide VALUE by VARIABLE` | `divide 2 by monthly_payment` |
+| **Helper Keywords** | `to` | - | Assignment target | `set VARIABLE to VALUE` | `set approval_status to "APPROVED"` |
+| | `as` | - | Calculation target | `calculate VARIABLE as EXPRESSION` | `calculate debt_ratio as monthlyDebt / annualIncome` |
+| | `with` | - | Function parameters | `call FUNCTION with [args]` | `call log with ["Message", "INFO"]` |
+| | `from` | - | Subtraction source | `subtract VALUE from VARIABLE` | `subtract penalty from total_score` |
+| | `by` | - | Factor for multiply/divide | `multiply VALUE by VARIABLE` | `multiply 1.5 by risk_factor` |
+| | `and` | - | Range separator | `VALUE between MIN and MAX` | `age between 18 and 65` |
+
+> **Grammar peculiarity for `multiply` / `divide`:** the value comes *first*, then the variable.
+> All four arithmetic actions follow the same shape: ``.
+> Read `multiply 1.5 by risk_factor` as "apply ×1.5 to `risk_factor`".
**Arithmetic Expression Examples:**
+
```yaml
then:
# Basic arithmetic in expressions
@@ -333,10 +511,11 @@ then:
- calculate remainder as loanAmount % 1000
# Arithmetic actions (modify existing variables)
+ # Grammar:
- add 50 to credit_score
- subtract late_fee from account_balance
- - multiply risk_score by 1.2
- - divide monthly_payment by 2
+ - multiply 1.2 by risk_score
+ - divide 2 by monthly_payment
# Complex expressions
- calculate debt_to_income as (monthlyDebt + proposedPayment) / (annualIncome / 12)
@@ -1017,19 +1196,35 @@ conditions:
- run uppercase as upper(name)
- run lowercase as lower(email)
- run trimmed as trim(input_text)
-- calculate length as length(description)
+- run name_length as length(description)
# Date/time functions
-- calculate current_date as now()
-- calculate formatted_date as format_date(date_value, "yyyy-MM-dd")
-- calculate age_years as calculate_age(birth_date)
-
-# Validation functions
-- calculate is_valid_email as validate_email(email_address)
-- calculate is_valid_phone as validate_phone(phone_number)
-- calculate is_business_day as is_business_day(date_value)
+- run current_date as now()
+- run today_date as today()
+- run formatted_date as format_date(date_value, "yyyy-MM-dd")
+- run pretty_date as format_date(date_value, "dd MMM yyyy")
+- run age_years as calculate_age(birth_date) # from today
+- run age_at_event as calculate_age(birth_date, event_date)
+- run plus_thirty as dateadd(today_date, 30, "days")
+- run days_between as datediff(start_date, end_date, "days")
+
+# Validation functions (function-call form complements the `is_email`/`is_phone` operators)
+- run email_ok as validate_email(email_address)
+- run phone_ok as validate_phone(phone_number)
+- run is_business_day_today as is_business_day(today_date)
+- run any_check as is_valid(value, "email") # 12 known types; unknown -> error
+
+# Null-handling & conditional helpers (DSL primitives)
+- run preferred_name as coalesce(nickname, full_name, "Anonymous") # first non-null wins
+- run tier as if_else(creditScore at_least 750, "PRIME", "STANDARD") # inline ternary
+- run within_window as is_in_range(score, 600, 850) # function form of `between`
```
+> **Important:** Function calls and REST/JSON expressions are only legal in `run` actions and in
+> expression contexts (function arguments, conditions, output mappings). The `calculate` action
+> is restricted to pure mathematical expressions (`+ - * / % **`) on numeric inputs --
+> attempting to use a function call inside `calculate` raises a clean validation error.
+
### REST Call Expressions
```yaml
@@ -1042,12 +1237,18 @@ conditions:
- run validation_result as rest_post("https://validator.com/check", {"email": email, "phone": phone})
# PUT requests with headers
-- calculate update_result as rest_put("https://api.example.com/users/123", user_data, {"Authorization": "Bearer " + token})
+- run update_result as rest_put("https://api.example.com/users/123", user_data, {"Authorization": "Bearer " + token})
# DELETE requests
-- calculate delete_result as rest_delete("https://api.example.com/records/" + record_id)
+- run delete_result as rest_delete("https://api.example.com/records/" + record_id)
```
+> **REST error contract:** On HTTP failure (non-2xx, network error, DNS, timeout), the REST
+> functions return a structured map: `{success: false, error: true, message: ""}`.
+> Rules can branch on `response.success` to handle errors gracefully. This is intentional
+> "chain-friendly" behaviour and is the only place in the engine where a failure does not
+> raise an exception; everywhere else, errors propagate as `success=false` on the rule result.
+
### JSON Path Expressions
```yaml
@@ -1092,6 +1293,19 @@ conditions:
- run power as pow(base, exponent)
- run square_root as sqrt(16)
+# Advanced math (added 26.05.08)
+- run e_value as exp(1) # e^x
+- run ln_value as ln(2.718) # natural log
+- run log10_value as log10(1000) # base-10 log
+- run sin_value as sin(0) # radians
+- run cos_value as cos(0)
+- run tan_value as tan(0)
+- run angle as atan2(1, 1) # two-argument arc tangent
+
+# Hashing (cryptographic digests)
+- run sig as hash("payload") # SHA-256, hex-encoded
+- run md5 as hash("payload", "MD5") # also SHA-1, SHA-512
+
# Statistical functions
- run average as avg(score1, score2, score3) # Also: average
- run sum as sum(amount1, amount2, amount3)
@@ -1106,91 +1320,150 @@ conditions:
# String manipulation
- run trimmed as trim(" hello ")
-- calculate length as length("hello") # Also: len
-- calculate substring as substring("hello", 1, 3) # Also: substr
-- calculate contains_check as contains("hello", "ell")
-- calculate starts_check as startswith("hello", "he")
-- calculate ends_check as endswith("hello", "lo")
-- calculate replaced as replace("hello", "l", "x")
+- run name_length as length("hello") # Also: len
+- run substring as substring("hello", 1, 3) # Also: substr
+- run contains_check as contains("hello", "ell")
+- run starts_check as startswith("hello", "he")
+- run ends_check as endswith("hello", "lo")
+- run replaced as replace("hello", "l", "x")
+
+# Templated formatting: {0}, {1}, ... substitute extra args by index.
+# Wrap the whole action in YAML quotes if the template contains `: ` (colon-space).
+- run greeting as format("Hello, {0}!", customerName)
+- 'run msg as format("Score {0} / {1} (decision {2})", score, maxScore, decision)'
+
+# String concatenation of N args (returns the joined string)
+- run id as concat(prefix, "-", customerId, "-", suffix)
```
### Financial Functions
```yaml
# Loan and interest calculations
-- calculate monthly_payment as calculate_loan_payment(principal, annual_rate, term_months)
-- calculate compound_interest as calculate_compound_interest(principal, rate, time)
-- calculate amortization as calculate_amortization(principal, rate, term)
-- calculate apr as calculate_apr(loan_amount, fees, monthly_payment, term)
+- run monthly_payment as calculate_loan_payment(principal, annual_rate, term_months)
+- run compound_interest as calculate_compound_interest(principal, rate, time)
+- run amortization as calculate_amortization(principal, rate, term)
+- run apr as calculate_apr(loan_amount, fees, monthly_payment, term)
# Financial ratios and metrics
-- calculate debt_ratio as debt_to_income_ratio(monthly_debt, monthly_income)
-- calculate credit_util as credit_utilization(used_credit, total_credit)
-- calculate ltv as loan_to_value(loan_amount, property_value)
-- calculate debt_ratio_alt as calculate_debt_ratio(total_debt, total_income)
-- calculate ltv_alt as calculate_ltv(loan_amount, property_value)
+- run debt_ratio as debt_to_income_ratio(monthly_debt, monthly_income)
+- run credit_util as credit_utilization(used_credit, total_credit)
+- run ltv as loan_to_value(loan_amount, property_value)
+- run debt_ratio_alt as calculate_debt_ratio(total_debt, total_income)
+- run ltv_alt as calculate_ltv(loan_amount, property_value)
# Credit and risk scoring
-- calculate credit_score as calculate_credit_score(payment_history, utilization, length, types, inquiries)
-- calculate risk_score as calculate_risk_score(credit_score, income, debt_ratio)
-- calculate payment_score as payment_history_score(payment_data)
+- run credit_score as calculate_credit_score(payment_history, utilization, length, types, inquiries)
+- run risk_score as calculate_risk_score(credit_score, income, debt_ratio)
+- run payment_score as payment_history_score(payment_data)
# Utility functions
- run formatted_amount as format_currency(1234.56)
-- calculate formatted_percent as format_percentage(0.15)
-- calculate account_num as generate_account_number()
-- calculate transaction_id as generate_transaction_id()
+- run formatted_percent as format_percentage(0.15)
+- run account_num as generate_account_number()
+- run transaction_id as generate_transaction_id()
```
### Date/Time Functions
```yaml
# Current date/time
-- calculate current_timestamp as now()
-- calculate current_date as today()
-
-# Date calculations
-- calculate date_plus as dateadd(date_value, amount, "days") # Also supports "months", "years"
-- calculate date_difference as datediff(start_date, end_date, "days")
-- calculate hour_value as time_hour(timestamp)
-
-# Date validation
-- calculate is_business_day as is_business_day(date_value)
-- calculate age_check as age_meets_requirement(birth_date, min_age)
+- run current_timestamp as now()
+- run current_date as today()
+- run iso_now as current_iso() # Also: now_iso() -- ISO-8601 with offset
+
+# Date arithmetic
+- run date_plus as dateadd(date_value, amount, "days") # Also: "months", "years", "weeks"
+- run date_difference as datediff(start_date, end_date, "days")
+- run hour_value as time_hour(timestamp)
+
+# Date field extractors (numeric output)
+- run year_num as year_of(date_value) # e.g. 2026
+- run month_num as month_of(date_value) # 1..12
+- run dom as day_of_month(date_value) # 1..31
+- run dow as day_of_week(date_value) # ISO: Monday=1 ... Sunday=7
+
+# Date validation / age
+- run is_business_day_check as is_business_day(date_value)
+- run age_check as age_meets_requirement(birth_date, min_age)
+- run formatted as format_date(date_value, "yyyy-MM-dd")
```
### List Functions
```yaml
-# List operations
-- calculate list_size as size(my_list) # Also: count
+# Basic aggregates
+- run list_size as size(my_list) # Also: count
- run list_sum as sum(number_list)
-- run list_average as avg(number_list) # Also: average
-- calculate first_item as first(my_list)
-- calculate last_item as last(my_list)
+- run list_average as avg(number_list) # Also: average
+- run first_item as first(my_list)
+- run last_item as last(my_list)
+
+# Ordering / dedup
+- run sorted_nums as sort(my_list) # ascending; numeric or Comparable items
+- run reversed_nums as reverse(my_list)
+- run unique_items as distinct(my_list) # de-dupe, preserving insertion order
+```
+
+### Higher-Order List Functions (by named function)
+
+The DSL has no inline-lambda syntax; `filter` / `map` / `reduce` / `find` take the
+predicate or transformer as a **string function name**. The named function is resolved
+through the same lookup the evaluator uses for any function call --
+[`CustomFunctionRegistry`](#custom-functions-extension-point) first, then the built-in
+catalogue -- so both engine built-ins and user-registered Spring beans work
+identically.
+
+```yaml
+# filter(list, function_name): keep items where the named predicate is truthy
+- run large_txns as filter(transactions, "is_above_threshold")
+
+# map(list, function_name): transform every item
+- run with_fees as map(transactions, "add_fee")
+
+# reduce(list, initial, function_name): accumulate left-to-right
+# The reducer is called as fn(accumulator, item) for each item.
+- run total as reduce(transactions, 0, "add_two")
+
+# find(list, function_name): first matching item, or null if none
+- run first_negative as find(balances, "is_negative_value")
+```
+
+> **Tip:** Register a one-arg `RuleFunction` from Java to act as the predicate /
+> transformer. For numeric predicates the engine already has `is_positive`,
+> `is_negative`, `is_zero`, `is_email`, `is_phone`, etc. -- those are reachable by
+> name too.
+
+### Statistical Aggregates
+
+```yaml
+- run mid as median(values) # numeric median (mean of middle two on even length)
+- run spread as stddev(values) # sample standard deviation (n-1 denominator)
+- run var as variance(values) # sample variance
+- run p95 as percentile(values, 95) # linear-interpolation percentile (p in [0,100])
```
### Type Conversion Functions
```yaml
# Type conversions
-- calculate as_number as tonumber("123.45") # Also: number
-- calculate as_string as tostring(123) # Also: string
-- calculate as_boolean as toboolean("true") # Also: boolean
+- run as_number as tonumber("123.45") # Also: number
+- run as_string as tostring(123) # Also: string
+- run as_boolean as toboolean("true") # Also: boolean
```
### Validation Functions
```yaml
# Financial validation
-- calculate is_valid_score as is_valid_credit_score(750)
-- calculate is_valid_ssn as is_valid_ssn("123-45-6789")
-- calculate is_valid_account as is_valid_account("1234567890")
-- calculate is_valid_routing as is_valid_routing("021000021")
+- run is_valid_score as is_valid_credit_score(750)
+- run is_valid_ssn as is_valid_ssn("123-45-6789")
+- run is_valid_account as is_valid_account("1234567890")
+- run is_valid_routing as is_valid_routing("021000021")
# General validation
-- calculate is_valid_data as is_valid(value, criteria)
-- calculate in_range_check as in_range(value, min, max)
+- run is_valid_data as is_valid(value, "email")
+- run in_range_check as in_range(value, min, max)
```
### REST API Functions
@@ -1199,10 +1472,10 @@ conditions:
# HTTP methods (all actually implemented)
- run get_response as rest_get(url)
- run post_response as rest_post(url, body)
-- calculate put_response as rest_put(url, body, headers)
-- calculate delete_response as rest_delete(url, headers)
-- calculate patch_response as rest_patch(url, body, headers)
-- calculate api_response as rest_call(method, url, body, headers)
+- run put_response as rest_put(url, body, headers)
+- run delete_response as rest_delete(url, headers)
+- run patch_response as rest_patch(url, body, headers)
+- run api_response as rest_call(method, url, body, headers)
```
### JSON Functions
@@ -1211,23 +1484,37 @@ conditions:
# JSON path operations (all actually implemented)
- run value as json_get(json_object, "path.to.property") # Also: json_path
- run exists as json_exists(json_object, "optional.property")
-- calculate size as json_size(json_object, "array_property")
-- calculate type as json_type(json_object, "property")
+- run size as json_size(json_object, "array_property")
+- run type as json_type(json_object, "property")
```
### Utility Functions
```yaml
# Distance and location
-- calculate distance as distance_between(lat1, lon1, lat2, lon2)
+- run distance as distance_between(lat1, lon1, lat2, lon2)
# Data security
-- calculate encrypted as encrypt(data, key)
-- calculate decrypted as decrypt(encrypted_data, key)
-- calculate masked as mask_data(sensitive_data, mask_pattern)
+- run encrypted as encrypt(data, key)
+- run decrypted as decrypt(encrypted_data, key)
+- run masked as mask_data(sensitive_data, mask_pattern)
# Advanced financial calculations
-- calculate payment_schedule as calculate_payment_schedule(principal, rate, term)
+- run payment_schedule as calculate_payment_schedule(principal, rate, term)
+```
+
+### Null-handling, Conditional, and Range Helpers
+
+```yaml
+# coalesce: first non-null wins (NULL-coalescing default)
+- run preferred_name as coalesce(nickname, full_name, "Anonymous")
+
+# if_else: inline ternary expression (avoids a full `if/then/else` action block)
+- run tier as if_else(creditScore at_least 750, "PRIME", "STANDARD")
+- run discount as if_else(membership equals "GOLD", 0.20, 0.05)
+
+# is_in_range: function form of the `between` operator (inclusive both ends)
+- run score_band_ok as is_in_range(score, 600, 850)
```
### Logging and Audit Functions
@@ -1237,22 +1524,176 @@ conditions:
- call audit with ["Decision made", "AUDIT"]
- call audit_log with ["Rule executed", "TRACE"]
- call send_notification with ["recipient", "message"]
+
+# First-class logging action -- routes through SLF4J at the named level
+- run echoed as log("Rule fired for applicant " + applicantId, "INFO")
+- call log with ["debug snapshot", "DEBUG"] # action-context invocation
```
+Supported levels (case-insensitive): `TRACE`, `DEBUG`, `INFO` (default), `WARN`, `ERROR`. The function returns its message so it can be chained inside expressions.
+
+### Rule Composition -- the `invoke_rule` Function
+
+`invoke_rule(code, ...)` evaluates another stored rule by code and returns its output map. Inputs are passed as **alternating "key", value pairs** trailing the rule code -- this avoids the YAML/JSON `{}` flow-mapping ambiguity that bites authors who try to write inline maps in action lines.
+
+```yaml
+then:
+ # No inputs
+ - run health as invoke_rule("system_health_check")
+
+ # Two-argument form (only when the second argument is already a Map in context)
+ - run score as invoke_rule("scoring_rule", existing_input_map)
+
+ # Alternating-pairs form (recommended for inline literals)
+ - run underwriting as invoke_rule("composite_underwriting",
+ "creditScore", creditScore,
+ "annualIncome", annualIncome,
+ "existingDebt", existingDebt)
+ - set tier to underwriting.tier
+ - set approved to underwriting.approved
+```
+
+The nested rule evaluation is synchronous (blocking on the engine's `boundedElastic` worker). If the invoked rule fails to parse, the rule code does not exist, or the nested evaluation reports `success=false`, `invoke_rule` raises an error that propagates as a failed result of the outer rule -- consistent with the fail-loud contract.
+
+> ⚠️ `invoke_rule` requires a `RuleInvoker` bean. The default Spring auto-configuration wires `RuleInvokerImpl` (backed by `RuleDefinitionService`) automatically; outside Spring you can supply your own implementation.
+
+### Custom Functions (Extension Point)
+
+Register your own functions in a Spring `@Bean` and call them from any rule:
+
+```java
+@Configuration
+class MyRulesConfig {
+ @Bean
+ CommandLineRunner registerCustomFunctions(CustomFunctionRegistry registry) {
+ return args -> {
+ registry.register("regional_risk", a ->
+ Set.of("CA", "NY").contains(a[0]) ? 10 : 0);
+ registry.register("fraud_score", a ->
+ fraudService.score(String.valueOf(a[0])));
+ };
+ }
+}
+```
+
+```yaml
+# Then use them like any built-in function:
+when:
+ - fraud_score(applicantId) at_most MAX_FRAUD_SCORE
+then:
+ - run risk_bump as regional_risk(region)
+ - run is_clean as fraud_score(applicantId) less_than 50
+```
+
+**Resolution order:** Custom functions are checked **before** the built-in catalog -- if you
+register a function with the same name as a built-in (e.g., `max`), your function wins.
+Names are matched case-insensitively. The same registered function is reachable from both
+expression contexts (`run` / `calculate` arg / condition) and action contexts (`call`).
+
---
## Advanced Features
-### Circuit Breaker Configuration
+### Circuit Breaker -- the `circuit_breaker` Action
+
+The DSL has no top-level `circuit_breaker:` config block. Resilience and early
+termination are expressed as an **action** inside a rule's `then:` block:
+
```yaml
-circuit_breaker:
- enabled: true
- failure_threshold: 5
- timeout_duration: "30s"
- recovery_timeout: "60s"
+then:
+ - if risk_score at_least 90 then circuit_breaker "HIGH_RISK_DETECTED"
+ - set processing_status to "OK" # never executes if the previous action triggered
```
+When the action fires, the engine stops the rule cleanly. The result reports
+`success=true` with `circuitBreakerTriggered=true` and the message above; any
+already-set variables remain in the output, but no subsequent actions run.
+
+### Decision Tables (DMN-style)
+
+A `decision_table:` block expresses a multi-row decision as a table of input
+predicates and output assignments. This is the most concise way to encode rules
+that boil down to "look at columns X, Y, Z; return outputs A, B" -- the same shape
+that drools/DMN solve.
+
+
+```yaml
+name: "Auto Insurance Premium Table"
+description: "Tier and rate by credit and age"
+
+inputs:
+ creditScore: number
+ age: number
+
+decision_table:
+ inputs: [creditScore, age]
+ outputs: [tier, rate]
+ hit_policy: FIRST
+ rules:
+ - when:
+ - creditScore at_least 750
+ - age between 25 and 65
+ then:
+ tier: "PRIME"
+ rate: 3.0
+ - when:
+ - creditScore at_least 650
+ then:
+ tier: "PREFERRED"
+ rate: 5.0
+ - otherwise: true
+ then:
+ tier: "STANDARD"
+ rate: 9.0
+
+output:
+ tier: tier
+ rate: rate
+```
+
+**Hit policies** -- how the engine picks which rows contribute:
+
+| Policy | Behavior |
+|----------|-------------------------------------------------------------------------------------|
+| `FIRST` | First matching row wins. Default. |
+| `ANY` | Any matching row's outputs apply; the engine uses the first match. |
+| `UNIQUE` | Exactly one row must match. Multiple matches -> rule fails with a clean diagnostic. |
+| `COLLECT`| Each output column is collected into a list of values from every matching row. |
+
+**Output values**: numbers, booleans, lists, and maps pass through unchanged. Strings are taken as literals by default. Prefix a string with `=` to mark it as a DSL expression evaluated against the current context:
+
+
+```yaml
+then:
+ tier: "PRIME" # literal string
+ rate: 3.0 # literal number
+ computed_premium: "= basePremium * 1.5" # expression -- result of basePremium * 1.5
+ band: "= if_else(score at_least 700, \"HIGH\", \"LOW\")"
+```
+
+This rule shape is mutually exclusive with `when:/then:`, `conditions:`, and `rules:` at the same level. If `decision_table:` is present it takes precedence.
+
+### Per-Rule Timeout
+
+Declare an upper bound on a rule's wall-clock runtime to protect callers from
+runaway loops, slow REST calls, or pathological data:
+
+
+```yaml
+name: "Risk Assessment"
+timeout: 5s # also accepts "500ms" or a raw number of milliseconds
+when:
+ - creditScore at_least 600
+then:
+ - run report as rest_get("https://slow.example.com/risk")
+ - set assessed to true
+```
+
+Exceeding the timeout fails the rule cleanly: `success=false` with an error like
+`Rule 'Risk Assessment' exceeded its declared timeout of 5000ms`. No partial outputs
+escape; the engine relies on Reactor's `Mono.timeout()` to cancel the work.
+
### Metadata and Versioning
```yaml
@@ -1432,12 +1873,12 @@ then:
- if has_address equals true then run zip_code as json_get(customer_data, "addressInfo.zipCode")
# Validation if required
- - if requiresValidation equals true then calculate email_valid as validate_email(customer_email)
- - if requiresValidation equals true then calculate phone_valid as validate_phone(customer_phone)
+ - if requiresValidation equals true then run email_valid as validate_email(customer_email)
+ - if requiresValidation equals true then run phone_valid as validate_phone(customer_phone)
# Set processing status
- set data_enrichment_complete to true
- - calculate processing_timestamp as now()
+ - run processing_timestamp as now()
else:
- set data_enrichment_complete to false
@@ -1502,7 +1943,7 @@ rules:
- loan_to_income_ratio less_than 5.0
then:
- set risk_assessment to "LOW"
- - calculate estimated_monthly_payment as calculate_loan_payment(loanAmount, 0.05, loanTerm)
+ - run estimated_monthly_payment as calculate_loan_payment(loanAmount, 0.05, loanTerm)
- set pre_approval_status to "APPROVED"
else:
- set risk_assessment to "HIGH"
@@ -1513,11 +1954,11 @@ rules:
- pre_approval_status equals "APPROVED"
then:
- set final_status to "APPROVED"
- - calculate approval_timestamp as now()
- - call log with ["Loan approved for amount: " + loanAmount, "INFO"]
+ - run approval_timestamp as now()
+ - 'call log with ["Loan approved for amount: " + loanAmount, "INFO"]'
else:
- set final_status to "DECLINED"
- - call log with ["Loan declined - " + rejection_reason, "INFO"]
+ - 'call log with ["Loan declined - " + rejection_reason, "INFO"]'
output:
validation_stage_1: text
@@ -1533,11 +1974,18 @@ output:
rejection_reason: text
```
-### Example 4: Advanced Validation with Complex Boolean Expressions (NEW)
+### Example 4: Advanced Validation with Complex Boolean Expressions
+
+This example exercises:
+
+- **Validation operators in `set ... to (...)` expressions** (`is_email`, `is_phone`, `is_credit_score`, `is_positive`, `is_not_null`, `is_not_empty`)
+- **Multi-line boolean composition** with `and`/`or` (lower-case keywords are canonical; `AND`/`OR` are accepted as case-insensitive aliases)
+- **`if_else(condition, then, else)`** as the inline-ternary replacement -- the DSL does **not** support C-style `? :`
+- **Sub-rules with shared state** -- variables set in earlier rules are visible to later ones
```yaml
name: "B2B Credit Scoring with Enhanced Validation"
-description: "Demonstrates new validation operators in complex expressions"
+description: "Demonstrates validation operators in complex expressions"
version: "2.1.0"
inputs:
@@ -1564,42 +2012,18 @@ rules:
- exists monthlyRevenue
- exists creditScore
then:
- # Complex boolean expressions with validation operators
- - set has_complete_financial_data to (
- monthlyRevenue is_positive AND
- monthlyExpenses is_positive AND
- existingDebt is_not_null AND
- monthlyDebtPayments is_positive AND
- verifiedAnnualRevenue is_positive
- )
-
- - set has_valid_contact_info to (
- customerName is_not_empty AND
- email is_email AND
- phone is_phone AND
- ssn is_ssn
- )
-
- - set has_valid_credit_data to (
- creditScore is_credit_score AND
- creditScore >= MIN_BUSINESS_CREDIT_SCORE
- )
+ - set has_complete_financial_data to (monthlyRevenue is_positive and monthlyExpenses is_positive and existingDebt is_not_null and monthlyDebtPayments is_positive and verifiedAnnualRevenue is_positive)
+ - set has_valid_contact_info to (customerName is_not_empty and email is_email and phone is_phone and ssn is_ssn)
+ - set has_valid_credit_data to (creditScore is_credit_score and creditScore >= MIN_BUSINESS_CREDIT_SCORE)
- name: "Financial Analysis"
when:
- has_complete_financial_data equals true
- has_valid_credit_data equals true
then:
- # Multi-line validation expressions
- - set meets_credit_requirements to (
- creditScore is_credit_score AND
- creditScore >= MIN_BUSINESS_CREDIT_SCORE AND
- (creditScore >= EXCELLENT_CREDIT_THRESHOLD OR verifiedAnnualRevenue >= 500000)
- )
-
+ - set meets_credit_requirements to (creditScore is_credit_score and creditScore >= MIN_BUSINESS_CREDIT_SCORE and (creditScore >= EXCELLENT_CREDIT_THRESHOLD or verifiedAnnualRevenue >= 500000))
- calculate debt_to_income_ratio as existingDebt / verifiedAnnualRevenue
- calculate monthly_cash_flow as monthlyRevenue - monthlyExpenses - monthlyDebtPayments
-
- set has_positive_cash_flow to (monthly_cash_flow is_positive)
- name: "Final Decision"
@@ -1609,11 +2033,11 @@ rules:
- has_positive_cash_flow equals true
then:
- set final_decision to "APPROVED"
- - calculate approval_score as (
- (creditScore >= EXCELLENT_CREDIT_THRESHOLD ? 40 : 20) +
- (monthly_cash_flow >= 10000 ? 30 : 15) +
- (debt_to_income_ratio <= 0.3 ? 30 : 10)
- )
+ # Score each factor with if_else() (no C-style ternary), then sum.
+ - run credit_score_pts as if_else(creditScore >= EXCELLENT_CREDIT_THRESHOLD, 40, 20)
+ - run cash_flow_pts as if_else(monthly_cash_flow >= 10000, 30, 15)
+ - run dti_pts as if_else(debt_to_income_ratio <= 0.3, 30, 10)
+ - calculate approval_score as credit_score_pts + cash_flow_pts + dti_pts
else:
- set final_decision to "DECLINED"
- set decline_reasons to []
@@ -1682,6 +2106,30 @@ output:
- Validation operators in conditional expressions
- Mixed validation and comparison operators in complex conditions
+### Version 26.05 - Fail-Loud Error Contract & New Primitives
+
+- **New primitives:** `coalesce(...)`, `if_else(cond, then, else)`, `is_in_range(value, low, high)`.
+- **New built-ins:** `calculate_age(birth[, asOf])`, `format_date(date[, pattern])`, `validate_email(value)`, `validate_phone(value)` (function-call form complements existing `is_email`/`is_phone` operators).
+- **Extension point:** `CustomFunctionRegistry` Spring bean lets applications register their own `RuleFunction` implementations callable from rules.
+- **Error contract:** the engine now fails loud by design rather than silently swallowing errors. See the table below.
+- **Sub-rule action parity:** sub-rules under `rules:` now accept the same map-shaped actions as the top level (e.g., YAML-collapsed `- forEach x in xs: ...`).
+
+#### Error Behavior Reference
+
+| Situation | Old (pre-26.05) | New |
+| -------------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
+| Unknown function name | `log.warn` + null | `IllegalArgumentException` -> rule reports `success=false` |
+| Non-numeric string in arithmetic | silently coerced to ZERO | `IllegalArgumentException` naming the operand |
+| Bad regex pattern in `matches` | `false` | `IllegalArgumentException` naming the pattern |
+| Missing bean property | `log.warn` + null | `IllegalArgumentException` naming class + property (maps still get null on missing key) |
+| Unknown `is_valid(value, type)` type | `false` | `IllegalArgumentException` listing supported types |
+| Unknown `dateadd`/`datediff` unit | `log.warn` + null | `IllegalArgumentException` listing supported units |
+| Action throws during execution | logged + next action runs | Rule reports `success=false` with action index + cause |
+| Condition throws during evaluation | silently flips to else branch | Rule reports `success=false` with the real cause |
+| `circuit_breaker` action triggered | (unchanged) | Rule reports `success=true` with `circuitBreakerTriggered=true` |
+| REST function HTTP failure | structured error map (unchanged) | Structured error map (unchanged -- intentional chain-friendly form) |
+| `set var to null` (e.g., `json_get` missing) | NPE caught silently | Variable stored as null; rule succeeds |
+
---
---
diff --git a/fireflyframework-rule-engine-core/pom.xml b/fireflyframework-rule-engine-core/pom.xml
index 190664d..3eb2063 100644
--- a/fireflyframework-rule-engine-core/pom.xml
+++ b/fireflyframework-rule-engine-core/pom.xml
@@ -63,12 +63,6 @@
provided
-
-
- org.apache.commons
- commons-math3
-
-
org.threeten
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java
index 09abda5..4704468 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java
@@ -136,29 +136,6 @@ public ConnectionFactory connectionFactory() {
return connectionPool;
}
- // Health indicator removed - requires actuator dependency
- // TODO: Add back when actuator is available
- /*
- @Bean
- public HealthIndicator connectionPoolHealthIndicator() {
- return () -> {
- try {
- return Health.up()
- .withDetail("pool.initialSize", initialSize)
- .withDetail("pool.maxSize", maxSize)
- .withDetail("pool.minIdle", minIdle)
- .withDetail("pool.maxIdleTime", maxIdleTime.toString())
- .withDetail("pool.maxAcquireTime", maxAcquireTime.toString())
- .build();
- } catch (Exception e) {
- return Health.down()
- .withDetail("error", e.getMessage())
- .build();
- }
- };
- }
- */
-
/**
* Scheduled task to log connection pool statistics.
* Helps monitor pool performance and identify potential issues.
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java
index 9a0a2fc..199f083 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java
@@ -39,17 +39,13 @@ public interface ASTVisitor {
T visitVariableExpression(VariableExpression node);
T visitLiteralExpression(LiteralExpression node);
T visitFunctionCallExpression(FunctionCallExpression node);
- T visitArithmeticExpression(ArithmeticExpression node);
- T visitJsonPathExpression(JsonPathExpression node);
- T visitRestCallExpression(RestCallExpression node);
-
+
// Condition visitors
T visitComparisonCondition(ComparisonCondition node);
T visitLogicalCondition(LogicalCondition node);
T visitExpressionCondition(ExpressionCondition node);
-
+
// Action visitors
- T visitAssignmentAction(AssignmentAction node);
T visitFunctionCallAction(FunctionCallAction node);
T visitConditionalAction(ConditionalAction node);
T visitCalculateAction(CalculateAction node);
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentAction.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentAction.java
deleted file mode 100644
index 502688a..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentAction.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.action;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import org.fireflyframework.rules.core.dsl.expression.Expression;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * Represents a variable assignment action.
- * Examples: set result to "approved", assign score to calculateScore(customer)
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class AssignmentAction extends Action {
-
- /**
- * Name of the variable to assign to
- */
- private String variableName;
-
- /**
- * Expression to evaluate and assign to the variable
- */
- private Expression value;
-
- /**
- * Assignment operator type
- */
- private AssignmentOperator operator;
-
- public AssignmentAction(SourceLocation location, String variableName, Expression value) {
- super(location);
- this.variableName = variableName;
- this.value = value;
- this.operator = AssignmentOperator.ASSIGN;
- }
-
- public AssignmentAction(SourceLocation location, String variableName, Expression value, AssignmentOperator operator) {
- super(location);
- this.variableName = variableName;
- this.value = value;
- this.operator = operator;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitAssignmentAction(this);
- }
-
- @Override
- public boolean hasVariableReferences() {
- return value.hasVariableReferences();
- }
-
- @Override
- public String toDebugString() {
- return String.format("%s %s %s", variableName, operator.getSymbol(), value.toDebugString());
- }
-}
-
-
-
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentOperator.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentOperator.java
deleted file mode 100644
index 17ca124..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentOperator.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.action;
-
-/**
- * Enumeration of assignment operators.
- */
-public enum AssignmentOperator {
- ASSIGN("to", "="),
- ADD_ASSIGN("add", "+="),
- SUBTRACT_ASSIGN("subtract", "-="),
- MULTIPLY_ASSIGN("multiply", "*="),
- DIVIDE_ASSIGN("divide", "/=");
-
- private final String keyword;
- private final String symbol;
-
- AssignmentOperator(String keyword, String symbol) {
- this.keyword = keyword;
- this.symbol = symbol;
- }
-
- public String getKeyword() {
- return keyword;
- }
-
- public String getSymbol() {
- return symbol;
- }
-
- public static AssignmentOperator fromKeyword(String keyword) {
- for (AssignmentOperator op : values()) {
- if (op.keyword.equals(keyword)) {
- return op;
- }
- }
- throw new IllegalArgumentException("Unknown assignment operator: " + keyword);
- }
-
- public static AssignmentOperator fromSymbol(String symbol) {
- for (AssignmentOperator op : values()) {
- if (symbol.equals(op.symbol)) {
- return op;
- }
- }
- throw new IllegalArgumentException("Unknown assignment operator symbol: " + symbol);
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java
deleted file mode 100644
index b801255..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java
+++ /dev/null
@@ -1,1335 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.compiler;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.action.*;
-import org.fireflyframework.rules.core.dsl.condition.*;
-import org.fireflyframework.rules.core.dsl.expression.*;
-import org.fireflyframework.rules.core.dsl.model.ASTRulesDSL;
-import org.fireflyframework.rules.core.services.ConstantService;
-import org.fireflyframework.rules.interfaces.dtos.crud.ConstantDTO;
-import org.fireflyframework.rules.interfaces.enums.ValueType;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Component;
-
-import java.time.OffsetDateTime;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.Map;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.Set;
-import java.util.HashSet;
-import java.util.stream.Collectors;
-
-/**
- * Complete Python code generator that compiles AST nodes to executable Python code.
- * This visitor traverses the entire AST and generates equivalent Python functions
- * that can be executed independently of the Java runtime.
- */
-@Component
-@Slf4j
-public class PythonCodeGenerator implements ASTVisitor {
-
- @Autowired
- private ConstantService constantService;
-
- private static final String PYTHON_RUNTIME_IMPORT = "from firefly_runtime import *";
-
- // Setter for testing purposes
- public void setConstantService(ConstantService constantService) {
- this.constantService = constantService;
- }
- private static final String CONTEXT_VAR = "context";
- private static final ThreadLocal indentLevel = ThreadLocal.withInitial(() -> 0);
-
- /**
- * Generate complete Python code for a rule DSL
- */
- public String generatePythonFunction(ASTRulesDSL rule) {
- return generatePythonFunction(rule, null);
- }
-
- /**
- * Generate complete Python code for a rule DSL with custom function name
- */
- public String generatePythonFunction(ASTRulesDSL rule, String functionName) {
- log.info("Generating Python code for rule '{}'", rule.getName());
-
- // Reset indent level for thread safety
- indentLevel.set(0);
-
- StringBuilder code = new StringBuilder();
-
- // Add imports and setup
- code.append(generateHeader(rule));
- code.append("\n\n");
-
- // Generate main function
- code.append(generateMainFunction(rule, functionName));
-
- // Generate helper functions if needed
- code.append(generateHelperFunctions(rule));
-
- // Generate interactive main section
- code.append(generateInteractiveMain(rule, functionName));
-
- String pythonCode = code.toString();
- log.debug("Generated Python code:\n{}", pythonCode);
-
- return pythonCode;
- }
-
- private String generateHeader(ASTRulesDSL rule) {
- StringBuilder header = new StringBuilder();
- header.append("#!/usr/bin/env python3\n");
- header.append("# -*- coding: utf-8 -*-\n");
- header.append("#\n");
- header.append("# Copyright 2024-2026 Firefly Software Foundation\n");
- header.append("#\n");
- header.append("# Licensed under the Apache License, Version 2.0 (the \"License\");\n");
- header.append("# you may not use this file except in compliance with the License.\n");
- header.append("# You may obtain a copy of the License at\n");
- header.append("#\n");
- header.append("# http://www.apache.org/licenses/LICENSE-2.0\n");
- header.append("#\n");
- header.append("# Unless required by applicable law or agreed to in writing, software\n");
- header.append("# distributed under the License is distributed on an \"AS IS\" BASIS,\n");
- header.append("# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n");
- header.append("# See the License for the specific language governing permissions and\n");
- header.append("# limitations under the License.\n");
- header.append("#\n");
- header.append("# Generated by Firefly Rule Engine Python Compiler\n");
- header.append("# Made with ❤️ by Firefly Software Foundation\n");
- header.append("# Compilation Date: ").append(OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)).append("\n");
- header.append("#\n");
- header.append("# Rule: ").append(sanitizeComment(rule.getName())).append("\n");
- if (rule.getDescription() != null) {
- header.append("# Description: ").append(sanitizeComment(rule.getDescription())).append("\n");
- }
- if (rule.getVersion() != null) {
- header.append("# Version: ").append(rule.getVersion()).append("\n");
- }
- header.append("#\n");
- header.append("\n");
- header.append(PYTHON_RUNTIME_IMPORT).append("\n");
- return header.toString();
- }
-
- private String generateMainFunction(ASTRulesDSL rule) {
- return generateMainFunction(rule, null);
- }
-
- private String generateMainFunction(ASTRulesDSL rule, String functionName) {
- StringBuilder func = new StringBuilder();
-
- // Function signature
- String finalFunctionName = functionName != null ?
- sanitizeFunctionName(functionName) :
- sanitizeFunctionName(rule.getName());
- func.append("def ").append(finalFunctionName).append("(");
- func.append(CONTEXT_VAR).append("):\n");
-
- incrementIndent();
-
- // Function docstring
- func.append(indent()).append('\"').append('\"').append('\"').append("\n");
- func.append(indent()).append("Rule: ").append(rule.getName()).append("\n");
- if (rule.getDescription() != null) {
- func.append(indent()).append(rule.getDescription()).append("\n");
- }
- func.append(indent()).append("\n");
- func.append(indent()).append("Args:\n");
- func.append(indent()).append(" context (dict): Execution context with input variables\n");
- func.append(indent()).append("\n");
- func.append(indent()).append("Returns:\n");
- func.append(indent()).append(" dict: Output variables\n");
- func.append(indent()).append('\"').append('\"').append('\"').append("\n");
-
- // Initialize constants if any
- if (rule.getConstants() != null && !rule.getConstants().isEmpty()) {
- func.append(generateConstantsInitialization(rule.getConstants()));
- }
-
- // Generate rule logic
- if (rule.isSimpleSyntax()) {
- func.append(generateSimpleSyntaxLogic(rule));
- } else if (rule.getRules() != null && !rule.getRules().isEmpty()) {
- func.append(generateComplexRulesLogic(rule.getRules()));
- } else if (rule.getConditions() != null) {
- func.append(generateConditionalBlockLogic(rule.getConditions()));
- }
-
- // Return output
- func.append(generateReturnStatement(rule));
-
- decrementIndent();
-
- return func.toString();
- }
-
- private String generateSimpleSyntaxLogic(ASTRulesDSL rule) {
- StringBuilder logic = new StringBuilder();
-
- // Generate condition evaluation
- if (rule.getWhenConditions() != null && !rule.getWhenConditions().isEmpty()) {
- logic.append(indent()).append("# Evaluate conditions\n");
- logic.append(indent()).append("if ");
-
- String conditions = rule.getWhenConditions().stream()
- .map(condition -> condition.accept(this))
- .collect(Collectors.joining(" and "));
-
- logic.append(conditions).append(":\n");
-
- incrementIndent();
-
- // Generate then actions
- if (rule.getThenActions() != null && !rule.getThenActions().isEmpty()) {
- logic.append(indent()).append("# Then actions\n");
- for (Action action : rule.getThenActions()) {
- logic.append(indent()).append(action.accept(this)).append("\n");
- }
- }
-
- decrementIndent();
-
- // Generate else actions if any
- if (rule.getElseActions() != null && !rule.getElseActions().isEmpty()) {
- logic.append(indent()).append("else:\n");
- incrementIndent();
- logic.append(indent()).append("# Else actions\n");
- for (Action action : rule.getElseActions()) {
- logic.append(indent()).append(action.accept(this)).append("\n");
- }
- decrementIndent();
- }
- }
-
- return logic.toString();
- }
-
- private String generateComplexRulesLogic(List rules) {
- StringBuilder logic = new StringBuilder();
-
- for (int i = 0; i < rules.size(); i++) {
- ASTRulesDSL.ASTSubRule subRule = rules.get(i);
- logic.append(indent()).append("# Sub-rule: ").append(subRule.getName() != null ? subRule.getName() : "Rule " + (i + 1)).append("\n");
-
- if (subRule.getWhenConditions() != null && !subRule.getWhenConditions().isEmpty()) {
- // Simple syntax sub-rule
- logic.append(generateSubRuleSimpleSyntax(subRule));
- } else if (subRule.getConditions() != null) {
- // Complex syntax sub-rule
- logic.append(generateSubRuleComplexSyntax(subRule));
- }
-
- if (i < rules.size() - 1) {
- logic.append("\n");
- }
- }
-
- return logic.toString();
- }
-
- private String generateSubRuleSimpleSyntax(ASTRulesDSL.ASTSubRule subRule) {
- StringBuilder logic = new StringBuilder();
-
- logic.append(indent()).append("if ");
- String conditions = subRule.getWhenConditions().stream()
- .map(condition -> condition.accept(this))
- .collect(Collectors.joining(" and "));
- logic.append(conditions).append(":\n");
-
- incrementIndent();
- if (subRule.getThenActions() != null && !subRule.getThenActions().isEmpty()) {
- for (Action action : subRule.getThenActions()) {
- logic.append(indent()).append(action.accept(this)).append("\n");
- }
- }
- decrementIndent();
-
- if (subRule.getElseActions() != null && !subRule.getElseActions().isEmpty()) {
- logic.append(indent()).append("else:\n");
- incrementIndent();
- for (Action action : subRule.getElseActions()) {
- logic.append(indent()).append(action.accept(this)).append("\n");
- }
- decrementIndent();
- }
-
- return logic.toString();
- }
-
- private String generateSubRuleComplexSyntax(ASTRulesDSL.ASTSubRule subRule) {
- return generateConditionalBlockLogic(subRule.getConditions());
- }
-
- private String generateConditionalBlockLogic(ASTRulesDSL.ASTConditionalBlock conditionalBlock) {
- StringBuilder logic = new StringBuilder();
-
- logic.append(indent()).append("if ").append(conditionalBlock.getIfCondition().accept(this)).append(":\n");
-
- incrementIndent();
- if (conditionalBlock.getThenBlock() != null) {
- logic.append(generateActionBlock(conditionalBlock.getThenBlock()));
- }
- decrementIndent();
-
- if (conditionalBlock.getElseBlock() != null) {
- logic.append(indent()).append("else:\n");
- incrementIndent();
- logic.append(generateActionBlock(conditionalBlock.getElseBlock()));
- decrementIndent();
- }
-
- return logic.toString();
- }
-
- private String generateActionBlock(ASTRulesDSL.ASTActionBlock actionBlock) {
- StringBuilder block = new StringBuilder();
-
- if (actionBlock.getActions() != null && !actionBlock.getActions().isEmpty()) {
- for (Action action : actionBlock.getActions()) {
- block.append(indent()).append(action.accept(this)).append("\n");
- }
- }
-
- if (actionBlock.getNestedConditions() != null) {
- block.append(generateConditionalBlockLogic(actionBlock.getNestedConditions()));
- }
-
- return block.toString();
- }
-
- private String generateConstantsInitialization(List constants) {
- StringBuilder init = new StringBuilder();
-
- init.append(indent()).append("# Initialize constants from database or default values\n");
- init.append(indent()).append("# NOTE: Constants marked as 'None' need to be configured in the database\n");
- init.append(indent()).append("# or updated manually before execution\n");
- init.append(indent()).append("constants = {}\n");
-
- // Get constants from database
- Map databaseConstants = new HashMap<>();
- if (constantService != null) {
- try {
- List constantCodes = constants.stream()
- .map(ASTRulesDSL.ASTConstantDefinition::getCode)
- .collect(Collectors.toList());
-
- constantService.getConstantsByCodes(constantCodes)
- .collectList()
- .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
- .block()
- .forEach(dto -> databaseConstants.put(dto.getCode(), dto));
- } catch (Exception e) {
- log.warn("Failed to load constants from database: {}", e.getMessage());
- }
- }
-
- for (ASTRulesDSL.ASTConstantDefinition constant : constants) {
- init.append(indent()).append("constants['").append(constant.getCode()).append("'] = ");
-
- // Check if constant exists in database
- ConstantDTO dbConstant = databaseConstants.get(constant.getCode());
- if (dbConstant != null && dbConstant.getCurrentValue() != null) {
- // Use database value
- init.append(formatPythonValue(dbConstant.getCurrentValue()));
- init.append(" # From database");
- } else if (constant.getDefaultValue() != null) {
- // Use default value from DSL
- init.append(formatPythonValue(constant.getDefaultValue()));
- init.append(" # Default value");
- } else {
- // No value available - set to None with warning
- init.append("None");
- init.append(" # WARNING: Constant not found in database and no default value provided");
- }
- init.append("\n");
- }
- init.append("\n");
-
- return init.toString();
- }
-
- private String generateReturnStatement(ASTRulesDSL rule) {
- StringBuilder returnStmt = new StringBuilder();
-
- returnStmt.append("\n").append(indent()).append("# Return output variables\n");
- returnStmt.append(indent()).append("return {\n");
-
- incrementIndent();
-
- // Include explicitly defined outputs
- if (rule.getOutput() != null && !rule.getOutput().isEmpty()) {
- for (Map.Entry output : rule.getOutput().entrySet()) {
- returnStmt.append(indent()).append("'").append(output.getKey()).append("': ");
- returnStmt.append(CONTEXT_VAR).append(".get('").append(output.getKey()).append("'),\n");
- }
- } else {
- // If no explicit outputs, include variables that are set in actions
- Set setVariables = extractSetVariables(rule);
- for (String variable : setVariables) {
- returnStmt.append(indent()).append("'").append(variable).append("': ");
- returnStmt.append(CONTEXT_VAR).append(".get('").append(variable).append("'),\n");
- }
- }
-
- decrementIndent();
-
- returnStmt.append(indent()).append("}\n");
-
- return returnStmt.toString();
- }
-
- private String generateHelperFunctions(ASTRulesDSL rule) {
- // For now, return empty string. Helper functions can be added later if needed
- return "";
- }
-
- // Expression visitors
- @Override
- public String visitBinaryExpression(BinaryExpression node) {
- String left = node.getLeft().accept(this);
- String right = node.getRight().accept(this);
- String operator = mapBinaryOperatorToPython(node.getOperator());
- return String.format("(%s %s %s)", left, operator, right);
- }
-
- @Override
- public String visitUnaryExpression(UnaryExpression node) {
- String operand = node.getOperand().accept(this);
- String operator = mapUnaryOperatorToPython(node.getOperator());
-
- // Check if this is a function call (validation operators)
- if (operator.startsWith("firefly_")) {
- return String.format("%s(%s)", operator, operand);
- } else {
- return String.format("(%s %s)", operator, operand);
- }
- }
-
- @Override
- public String visitVariableExpression(VariableExpression node) {
- if (node.hasPropertyAccess()) {
- return String.format("get_nested_value(%s, '%s')", CONTEXT_VAR, node.getFullPath());
- } else if (node.hasIndexAccess()) {
- String indexExpr = node.getIndexExpression().accept(this);
- return String.format("get_indexed_value(%s, '%s', %s)", CONTEXT_VAR, node.getVariableName(), indexExpr);
- } else {
- // Check if this is a constant (UPPER_CASE_WITH_UNDERSCORES pattern)
- if (isConstantName(node.getVariableName())) {
- // Constants are accessed from the constants dictionary
- return String.format("constants.get('%s', None)", node.getVariableName());
- } else {
- // Regular variables are accessed from context
- String defaultValue = getDefaultValueForVariable(node.getVariableName());
- return String.format("%s.get('%s', %s)", CONTEXT_VAR, node.getVariableName(), defaultValue);
- }
- }
- }
-
- /**
- * Check if a variable name follows the constant naming convention (UPPER_CASE_WITH_UNDERSCORES)
- */
- private boolean isConstantName(String name) {
- return name != null && name.matches("^[A-Z][A-Z0-9_]*$");
- }
-
- private String getDefaultValueForVariable(String variableName) {
- // Common numeric variables get 0 as default
- if (variableName.toLowerCase().contains("score") ||
- variableName.toLowerCase().contains("amount") ||
- variableName.toLowerCase().contains("value") ||
- variableName.toLowerCase().contains("count") ||
- variableName.toLowerCase().contains("number") ||
- variableName.toLowerCase().contains("income") ||
- variableName.toLowerCase().contains("debt") ||
- variableName.toLowerCase().contains("age") ||
- variableName.toLowerCase().contains("price") ||
- variableName.toLowerCase().contains("cost") ||
- variableName.toLowerCase().contains("rate") ||
- variableName.toLowerCase().contains("percent")) {
- return "0";
- }
- // String variables get empty string
- return "''";
- }
-
- @Override
- public String visitLiteralExpression(LiteralExpression node) {
- return formatPythonValue(node.getValue());
- }
-
- @Override
- public String visitFunctionCallExpression(FunctionCallExpression node) {
- String functionName = mapFunctionToPython(node.getFunctionName());
-
- if (node.hasArguments()) {
- String args = node.getArguments().stream()
- .map(arg -> arg.accept(this))
- .collect(Collectors.joining(", "));
- return String.format("%s(%s)", functionName, args);
- } else {
- return String.format("%s()", functionName);
- }
- }
-
- @Override
- public String visitArithmeticExpression(ArithmeticExpression node) {
- // ArithmeticExpression is a complex expression with multiple operands
- String operation = node.getOperation().getName();
- List operands = node.getOperands().stream()
- .map(operand -> operand.accept(this))
- .collect(Collectors.toList());
-
- return switch (operation.toLowerCase()) {
- case "sum" -> String.format("sum([%s])", String.join(", ", operands));
- case "average", "avg" -> String.format("statistics.mean([%s])", String.join(", ", operands));
- case "max" -> String.format("max(%s)", String.join(", ", operands));
- case "min" -> String.format("min(%s)", String.join(", ", operands));
- default -> String.format("firefly_%s(%s)", operation, String.join(", ", operands));
- };
- }
-
- @Override
- public String visitJsonPathExpression(JsonPathExpression node) {
- String sourceExpression = node.getSourceExpression().accept(this);
- String jsonPath = "\"" + node.getJsonPath() + "\"";
- return String.format("json_path_get(%s, %s)", sourceExpression, jsonPath);
- }
-
- @Override
- public String visitRestCallExpression(RestCallExpression node) {
- String method = "\"" + node.getHttpMethod() + "\"";
- String url = node.getUrlExpression().accept(this);
-
- StringBuilder restCall = new StringBuilder();
- restCall.append("rest_call(").append(method).append(", ").append(url);
-
- if (node.getBodyExpression() != null) {
- restCall.append(", ").append(node.getBodyExpression().accept(this));
- } else {
- restCall.append(", None");
- }
-
- if (node.getHeadersExpression() != null) {
- restCall.append(", ").append(node.getHeadersExpression().accept(this));
- } else {
- restCall.append(", None");
- }
-
- if (node.getTimeoutExpression() != null) {
- restCall.append(", ").append(node.getTimeoutExpression().accept(this));
- }
-
- restCall.append(")");
- return restCall.toString();
- }
-
- // Condition visitors
- @Override
- public String visitComparisonCondition(ComparisonCondition node) {
- String left = node.getLeft().accept(this);
- String operator = mapComparisonOperatorToPython(node.getOperator());
- String right = node.getRight().accept(this);
-
- // Handle special operators
- return switch (node.getOperator()) {
- case BETWEEN -> {
- String rangeEnd = node.getRangeEnd().accept(this);
- yield String.format("(%s <= %s <= %s)", right, left, rangeEnd);
- }
- case NOT_BETWEEN -> {
- String rangeEnd = node.getRangeEnd().accept(this);
- yield String.format("not (%s <= %s <= %s)", right, left, rangeEnd);
- }
- case IN, IN_LIST -> String.format("(%s in %s)", left, right);
- case NOT_IN, NOT_IN_LIST -> String.format("(%s not in %s)", left, right);
- case CONTAINS -> String.format("(%s in %s)", right, left);
- case NOT_CONTAINS -> String.format("(%s not in %s)", right, left);
- case STARTS_WITH -> String.format("%s.startswith(%s)", left, right);
- case ENDS_WITH -> String.format("%s.endswith(%s)", left, right);
- case MATCHES -> String.format("re.match(%s, %s) is not None", right, left);
- case NOT_MATCHES -> String.format("re.match(%s, %s) is None", right, left);
- case IS_NULL -> String.format("(%s is None)", left);
- case IS_NOT_NULL -> String.format("(%s is not None)", left);
- case IS_EMPTY -> String.format("firefly_is_empty(%s)", left);
- case IS_NOT_EMPTY -> String.format("firefly_is_not_empty(%s)", left);
- // Validation operators that are function calls
- case IS_POSITIVE -> String.format("firefly_is_positive(%s)", left);
- case IS_NEGATIVE -> String.format("firefly_is_negative(%s)", left);
- case IS_ZERO -> String.format("firefly_is_zero(%s)", left);
- case IS_NON_ZERO -> String.format("firefly_is_non_zero(%s)", left);
- case IS_NUMERIC -> String.format("firefly_is_numeric(%s)", left);
- case IS_NOT_NUMERIC -> String.format("firefly_is_not_numeric(%s)", left);
- case IS_EMAIL -> String.format("firefly_is_email(%s)", left);
- case IS_PHONE -> String.format("firefly_is_phone(%s)", left);
- case IS_DATE -> String.format("firefly_is_date(%s)", left);
- case IS_PERCENTAGE -> String.format("firefly_is_percentage(%s)", left);
- case IS_CURRENCY -> String.format("firefly_is_currency(%s)", left);
- case IS_CREDIT_SCORE -> String.format("firefly_is_valid_credit_score(%s)", left);
- case IS_SSN -> String.format("firefly_is_ssn(%s)", left);
- case IS_ACCOUNT_NUMBER -> String.format("firefly_is_account_number(%s)", left);
- case IS_ROUTING_NUMBER -> String.format("firefly_is_routing_number(%s)", left);
- case IS_BUSINESS_DAY -> String.format("firefly_is_business_day(%s)", left);
- case IS_WEEKEND -> String.format("firefly_is_weekend(%s)", left);
- default -> String.format("(%s %s %s)", left, operator, right);
- };
- }
-
- @Override
- public String visitLogicalCondition(LogicalCondition node) {
- List operands = node.getOperands().stream()
- .map(operand -> operand.accept(this))
- .collect(Collectors.toList());
-
- return switch (node.getOperator()) {
- case AND -> String.format("(%s)", String.join(" and ", operands));
- case OR -> String.format("(%s)", String.join(" or ", operands));
- case NOT -> String.format("(not %s)", operands.get(0));
- };
- }
-
- @Override
- public String visitExpressionCondition(ExpressionCondition node) {
- return String.format("bool(%s)", node.getExpression().accept(this));
- }
-
- // Action visitors
- @Override
- public String visitAssignmentAction(AssignmentAction node) {
- String value = node.getValue().accept(this);
- String varName = sanitizeVariableName(node.getVariableName());
-
- return switch (node.getOperator()) {
- case ASSIGN -> String.format("%s['%s'] = %s", CONTEXT_VAR, varName, value);
- case ADD_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 0) + %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case SUBTRACT_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 0) - %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case MULTIPLY_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 1) * %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case DIVIDE_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 1) / %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- };
- }
-
- @Override
- public String visitFunctionCallAction(FunctionCallAction node) {
- String functionName = mapFunctionToPython(node.getFunctionName());
-
- StringBuilder call = new StringBuilder();
-
- if (node.hasResultVariable()) {
- call.append(String.format("%s['%s'] = ", CONTEXT_VAR, node.getResultVariable()));
- }
-
- call.append(functionName).append("(");
-
- if (node.hasArguments()) {
- String args = node.getArguments().stream()
- .map(arg -> arg.accept(this))
- .collect(Collectors.joining(", "));
- call.append(args);
- }
-
- call.append(")");
-
- return call.toString();
- }
-
- @Override
- public String visitConditionalAction(ConditionalAction node) {
- StringBuilder conditional = new StringBuilder();
-
- conditional.append("if ").append(node.getCondition().accept(this)).append(":\n");
-
- incrementIndent();
- if (node.getThenActions() != null && !node.getThenActions().isEmpty()) {
- for (Action action : node.getThenActions()) {
- conditional.append(indent()).append(action.accept(this)).append("\n");
- }
- }
- decrementIndent();
-
- if (node.hasElseActions()) {
- conditional.append(indent()).append("else:\n");
- incrementIndent();
- for (Action action : node.getElseActions()) {
- conditional.append(indent()).append(action.accept(this)).append("\n");
- }
- decrementIndent();
- }
-
- return conditional.toString().trim();
- }
-
- @Override
- public String visitCalculateAction(CalculateAction node) {
- String expression = node.getExpression().accept(this);
- String resultVar = node.getResultVariable();
- return String.format("%s['%s'] = %s", CONTEXT_VAR, resultVar, expression);
- }
-
- @Override
- public String visitRunAction(RunAction node) {
- String expression = node.getExpression().accept(this);
- String resultVar = node.getResultVariable();
- return String.format("%s['%s'] = %s", CONTEXT_VAR, resultVar, expression);
- }
-
- @Override
- public String visitSetAction(SetAction node) {
- String value = node.getValue().accept(this);
- String varName = sanitizeVariableName(node.getVariableName());
- return String.format("%s['%s'] = %s", CONTEXT_VAR, varName, value);
- }
-
- @Override
- public String visitArithmeticAction(ArithmeticAction node) {
- String value = node.getValue().accept(this);
- String varName = node.getVariableName();
-
- return switch (node.getOperation()) {
- case ADD -> String.format("%s['%s'] = %s.get('%s', 0) + %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case SUBTRACT -> String.format("%s['%s'] = %s.get('%s', 0) - %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case MULTIPLY -> String.format("%s['%s'] = %s.get('%s', 1) * %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case DIVIDE -> String.format("%s['%s'] = %s.get('%s', 1) / %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- default -> String.format("# Unsupported arithmetic operation: %s", node.getOperation());
- };
- }
-
- @Override
- public String visitListAction(ListAction node) {
- String value = node.getValue().accept(this);
- String listVar = node.getListVariable();
-
- return switch (node.getOperation()) {
- case APPEND -> String.format("%s.setdefault('%s', []).append(%s)",
- CONTEXT_VAR, listVar, value);
- case PREPEND -> String.format("%s.setdefault('%s', []).insert(0, %s)",
- CONTEXT_VAR, listVar, value);
- case REMOVE -> String.format("list_remove(%s.get('%s', []), %s)",
- CONTEXT_VAR, listVar, value);
- };
- }
-
- @Override
- public String visitCircuitBreakerAction(CircuitBreakerAction node) {
- String message = "\"" + node.getMessage() + "\"";
- String errorCode = node.getErrorCode() != null ? "\"" + node.getErrorCode() + "\"" : "None";
- return String.format("raise CircuitBreakerException(%s, %s)",
- message, errorCode);
- }
-
- @Override
- public String visitForEachAction(ForEachAction node) {
- StringBuilder code = new StringBuilder();
- String listExpr = node.getListExpression().accept(this);
- String iterVar = node.getIterationVariable();
-
- // Generate for loop
- if (node.hasIndexVariable()) {
- String indexVar = node.getIndexVariable();
- code.append(String.format("for %s, %s in enumerate(%s):\n", indexVar, iterVar, listExpr));
- } else {
- code.append(String.format("for %s in %s:\n", iterVar, listExpr));
- }
-
- // Indent and generate body actions
- incrementIndent();
- for (Action bodyAction : node.getBodyActions()) {
- code.append(indent()).append(bodyAction.accept(this)).append("\n");
- }
- decrementIndent();
-
- return code.toString();
- }
-
- @Override
- public String visitWhileAction(WhileAction node) {
- StringBuilder code = new StringBuilder();
- String condition = node.getCondition().accept(this);
-
- // Generate while loop with iteration limit
- code.append(String.format("_while_iterations = 0\n"));
- code.append(indent()).append(String.format("while %s and _while_iterations < %d:\n", condition, node.getMaxIterations()));
-
- // Indent and generate body actions
- incrementIndent();
- for (Action bodyAction : node.getBodyActions()) {
- code.append(indent()).append(bodyAction.accept(this)).append("\n");
- }
- code.append(indent()).append("_while_iterations += 1\n");
- decrementIndent();
-
- return code.toString();
- }
-
- @Override
- public String visitDoWhileAction(DoWhileAction node) {
- StringBuilder code = new StringBuilder();
- String condition = node.getCondition().accept(this);
-
- // Generate do-while loop (Python doesn't have do-while, so we use while True with break)
- code.append("_dowhile_iterations = 0\n");
- code.append(indent()).append("while True:\n");
-
- // Indent and generate body actions
- incrementIndent();
- for (Action bodyAction : node.getBodyActions()) {
- code.append(indent()).append(bodyAction.accept(this)).append("\n");
- }
- code.append(indent()).append("_dowhile_iterations += 1\n");
- code.append(indent()).append(String.format("if not (%s) or _dowhile_iterations >= %d:\n", condition, node.getMaxIterations()));
- incrementIndent();
- code.append(indent()).append("break\n");
- decrementIndent();
- decrementIndent();
-
- return code.toString();
- }
-
- // Utility methods
- private String indent() {
- return " ".repeat(indentLevel.get());
- }
-
- private void incrementIndent() {
- indentLevel.set(indentLevel.get() + 1);
- }
-
- private void decrementIndent() {
- indentLevel.set(indentLevel.get() - 1);
- }
-
- private String sanitizeComment(String text) {
- if (text == null) return "";
- return text.replace("\n", " ").replace("\r", " ");
- }
-
- private String sanitizeVariableName(String name) {
- if (name == null || !name.matches("^[a-zA-Z_][a-zA-Z0-9_.]*$")) {
- throw new IllegalArgumentException("Invalid variable name for Python code generation: " + name);
- }
- return name;
- }
-
- public String sanitizeFunctionName(String name) {
- if (name == null) return "unnamed_rule";
-
- String sanitized = name.toLowerCase()
- .replaceAll("[^a-zA-Z0-9_]", "_")
- .replaceAll("_{2,}", "_")
- .replaceAll("^_|_$", "");
-
- // Add underscore prefix if starts with number
- if (sanitized.matches("^[0-9].*")) {
- sanitized = "_" + sanitized;
- }
-
- return sanitized;
- }
-
- private String formatPythonValue(Object value) {
- if (value == null) {
- return "None";
- } else if (value instanceof String) {
- String escaped = value.toString()
- .replace("\\", "\\\\")
- .replace("\"", "\\\"")
- .replace("\n", "\\n")
- .replace("\r", "\\r")
- .replace("\t", "\\t")
- .replace("\0", "\\0");
- return "\"" + escaped + "\"";
- } else if (value instanceof Boolean) {
- return ((Boolean) value) ? "True" : "False";
- } else if (value instanceof List) {
- List> list = (List>) value;
- String elements = list.stream()
- .map(this::formatPythonValue)
- .collect(Collectors.joining(", "));
- return "[" + elements + "]";
- } else if (value instanceof Map) {
- Map, ?> map = (Map, ?>) value;
- String entries = map.entrySet().stream()
- .map(entry -> formatPythonValue(entry.getKey()) + ": " + formatPythonValue(entry.getValue()))
- .collect(Collectors.joining(", "));
- return "{" + entries + "}";
- } else {
- return value.toString();
- }
- }
-
-
-
-
-
- public List extractInputVariables(ASTRulesDSL rule) {
- if (rule.getInput() != null) {
- return new ArrayList<>(rule.getInput().keySet());
- }
- return new ArrayList<>();
- }
-
- public Map extractOutputVariables(ASTRulesDSL rule) {
- if (rule.getOutput() != null) {
- return rule.getOutput();
- }
- return new HashMap<>();
- }
-
-
- /**
- * Extract variables that are set in actions throughout the rule
- */
- private Set extractSetVariables(ASTRulesDSL rule) {
- Set setVariables = new HashSet<>();
-
- // Check simple syntax actions
- if (rule.getThenActions() != null) {
- setVariables.addAll(extractSetVariablesFromActions(rule.getThenActions()));
- }
- if (rule.getElseActions() != null) {
- setVariables.addAll(extractSetVariablesFromActions(rule.getElseActions()));
- }
-
- // Check complex syntax rules
- if (rule.getRules() != null) {
- for (ASTRulesDSL.ASTSubRule astRule : rule.getRules()) {
- if (astRule.getThenActions() != null) {
- setVariables.addAll(extractSetVariablesFromActions(astRule.getThenActions()));
- }
- if (astRule.getElseActions() != null) {
- setVariables.addAll(extractSetVariablesFromActions(astRule.getElseActions()));
- }
- }
- }
-
- return setVariables;
- }
-
- private Set extractSetVariablesFromActions(List actions) {
- Set setVariables = new HashSet<>();
-
- for (Action action : actions) {
- if (action instanceof SetAction) {
- SetAction setAction = (SetAction) action;
- setVariables.add(setAction.getVariableName());
- } else if (action instanceof CalculateAction) {
- CalculateAction calcAction = (CalculateAction) action;
- setVariables.add(calcAction.getResultVariable());
- } else if (action instanceof AssignmentAction) {
- AssignmentAction assignAction = (AssignmentAction) action;
- setVariables.add(assignAction.getVariableName());
- }
- }
-
- return setVariables;
- }
- private String mapBinaryOperatorToPython(BinaryOperator operator) {
- return switch (operator) {
- // Arithmetic operators
- case ADD -> "+";
- case SUBTRACT -> "-";
- case MULTIPLY -> "*";
- case DIVIDE -> "/";
- case MODULO -> "%";
- case POWER -> "**";
-
- // Comparison operators
- case EQUALS -> "==";
- case NOT_EQUALS -> "!=";
- case GREATER_THAN -> ">";
- case LESS_THAN -> "<";
- case GREATER_THAN_OR_EQUAL -> ">=";
- case LESS_THAN_OR_EQUAL -> "<=";
- case GREATER_EQUAL -> ">="; // Alias
- case LESS_EQUAL -> "<="; // Alias
-
- // String operators
- case CONTAINS -> " in ";
- case NOT_CONTAINS -> " not in ";
- case STARTS_WITH -> ".startswith";
- case ENDS_WITH -> ".endswith";
- case MATCHES -> "firefly_matches";
- case NOT_MATCHES -> "firefly_not_matches";
-
- // Logical operators
- case AND -> " and ";
- case OR -> " or ";
-
- // Special operators
- case BETWEEN -> "firefly_between";
- case NOT_BETWEEN -> "firefly_not_between";
- case IN -> " in ";
- case IN_LIST -> " in "; // Alias
- case NOT_IN_LIST -> " not in ";
-
- // Age validation operators
- case AGE_AT_LEAST -> "firefly_age_at_least";
- case AGE_LESS_THAN -> "firefly_age_less_than";
-
- default -> throw new UnsupportedOperationException("Unsupported binary operator: " + operator);
- };
- }
-
- private String mapUnaryOperatorToPython(UnaryOperator operator) {
- return switch (operator) {
- // Arithmetic operators
- case NEGATE, MINUS -> "-";
- case POSITIVE, PLUS -> "+";
-
- // Logical operators
- case NOT -> "not ";
-
- // Existence operators
- case EXISTS -> "firefly_exists";
- case IS_NULL -> "firefly_is_null";
- case IS_NOT_NULL -> "firefly_is_not_null";
-
- // Type checking operators
- case IS_NUMBER -> "firefly_is_number";
- case IS_STRING -> "firefly_is_string";
- case IS_BOOLEAN -> "firefly_is_boolean";
- case IS_LIST -> "firefly_is_list";
-
- // String operators
- case TO_UPPER -> ".upper()";
- case TO_LOWER -> ".lower()";
- case TRIM -> ".strip()";
- case LENGTH -> "len";
-
- // Validation operators
- case IS_POSITIVE -> "firefly_is_positive";
- case IS_NEGATIVE -> "firefly_is_negative";
- case IS_ZERO -> "firefly_is_zero";
- case IS_EMPTY -> "firefly_is_empty";
- case IS_NOT_EMPTY -> "firefly_is_not_empty";
- case IS_NUMERIC -> "firefly_is_numeric";
- case IS_NOT_NUMERIC -> "firefly_is_not_numeric";
- case IS_EMAIL -> "firefly_is_email";
- case IS_PHONE -> "firefly_is_phone";
- case IS_DATE -> "firefly_is_date";
- case IS_PERCENTAGE -> "firefly_is_percentage";
- case IS_CURRENCY -> "firefly_is_currency";
- case IS_CREDIT_SCORE -> "firefly_is_valid_credit_score";
- case IS_SSN -> "firefly_is_ssn";
- case IS_ACCOUNT_NUMBER -> "firefly_is_account_number";
- case IS_ROUTING_NUMBER -> "firefly_is_routing_number";
- case IS_BUSINESS_DAY -> "firefly_is_business_day";
- case IS_WEEKEND -> "firefly_is_weekend";
-
- default -> throw new UnsupportedOperationException("Unsupported unary operator: " + operator);
- };
- }
-
- private String mapComparisonOperatorToPython(ComparisonOperator operator) {
- return switch (operator) {
- // Basic comparison operators
- case EQUALS -> "==";
- case NOT_EQUALS -> "!=";
- case LESS_THAN -> "<";
- case LESS_THAN_OR_EQUAL, LESS_EQUAL -> "<=";
- case GREATER_THAN -> ">";
- case GREATER_THAN_OR_EQUAL, GREATER_EQUAL -> ">=";
-
- // String operators
- case CONTAINS -> " in ";
- case NOT_CONTAINS -> " not in ";
- case STARTS_WITH -> ".startswith";
- case ENDS_WITH -> ".endswith";
- case MATCHES -> "firefly_matches";
- case NOT_MATCHES -> "firefly_not_matches";
-
- // Special operators
- case BETWEEN -> "firefly_between";
- case NOT_BETWEEN -> "firefly_not_between";
- case IN, IN_LIST -> " in ";
- case NOT_IN, NOT_IN_LIST -> " not in ";
- case EXISTS -> "firefly_exists";
- case NOT_EXISTS -> "firefly_not_exists";
- case IS_NULL -> "firefly_is_null";
- case IS_NOT_NULL -> "firefly_is_not_null";
-
- // Basic validation operators
- case IS_EMPTY -> "firefly_is_empty";
- case IS_NOT_EMPTY -> "firefly_is_not_empty";
- case IS_NUMERIC -> "firefly_is_numeric";
- case IS_NOT_NUMERIC -> "firefly_is_not_numeric";
- case IS_EMAIL -> "firefly_is_email";
- case IS_PHONE -> "firefly_is_phone";
- case IS_DATE -> "firefly_is_date";
-
- // Financial validation operators
- case IS_POSITIVE -> "firefly_is_positive";
- case IS_NEGATIVE -> "firefly_is_negative";
- case IS_ZERO -> "firefly_is_zero";
- case IS_NON_ZERO -> "firefly_is_non_zero";
- case IS_PERCENTAGE -> "firefly_is_percentage";
- case IS_CURRENCY -> "firefly_is_currency";
- case IS_CREDIT_SCORE -> "firefly_is_valid_credit_score";
- case IS_SSN -> "firefly_is_ssn";
- case IS_ACCOUNT_NUMBER -> "firefly_is_account_number";
- case IS_ROUTING_NUMBER -> "firefly_is_routing_number";
-
- // Date/time validation operators
- case IS_BUSINESS_DAY -> "firefly_is_business_day";
- case IS_WEEKEND -> "firefly_is_weekend";
- case AGE_AT_LEAST -> "firefly_age_at_least";
- case AGE_LESS_THAN -> "firefly_age_less_than";
-
- // Length operators
- case LENGTH_EQUALS -> "firefly_length_equals";
- case LENGTH_GREATER_THAN -> "firefly_length_greater_than";
- case LENGTH_LESS_THAN -> "firefly_length_less_than";
-
- default -> throw new UnsupportedOperationException("Unsupported comparison operator: " + operator);
- };
- }
-
- private String mapArithmeticOperationToPython(ArithmeticOperation operation) {
- return switch (operation) {
- // Basic arithmetic
- case ADD -> "+";
- case SUBTRACT -> "-";
- case MULTIPLY -> "*";
- case DIVIDE -> "/";
- case MODULO -> "%";
- case POWER -> "**";
-
- // Mathematical functions
- case ABS -> "abs";
- case MIN -> "min";
- case MAX -> "max";
- case ROUND -> "round";
- case FLOOR -> "math.floor";
- case CEIL -> "math.ceil";
- case SQRT -> "math.sqrt";
- case SUM -> "sum";
- case AVERAGE -> "firefly_average";
-
- default -> throw new UnsupportedOperationException("Unsupported arithmetic operation: " + operation);
- };
- }
-
- private String mapFunctionToPython(String functionName) {
- return switch (functionName.toLowerCase()) {
- // Built-in Python functions
- case "max" -> "max";
- case "min" -> "min";
- case "abs" -> "abs";
- case "round" -> "round";
- case "sum" -> "sum";
- case "len" -> "len";
-
- // Math functions
- case "ceil" -> "math.ceil";
- case "floor" -> "math.floor";
- case "sqrt" -> "math.sqrt";
- case "pow" -> "pow";
-
- // String functions
- case "upper" -> "str.upper";
- case "lower" -> "str.lower";
- case "substring" -> "firefly_substring";
- case "length" -> "len";
- case "trim" -> "str.strip";
-
- // Date functions
- case "now" -> "datetime.now";
- case "format_date" -> "firefly_format_date";
- case "calculate_age" -> "firefly_calculate_age";
-
- // Financial functions
- case "calculate_loan_payment" -> "firefly_calculate_loan_payment";
- case "calculate_compound_interest" -> "firefly_calculate_compound_interest";
- case "calculate_amortization" -> "firefly_calculate_amortization";
- case "debt_to_income_ratio" -> "firefly_debt_to_income_ratio";
- case "credit_utilization" -> "firefly_credit_utilization";
- case "loan_to_value" -> "firefly_loan_to_value";
- case "calculate_apr" -> "firefly_calculate_apr";
- case "calculate_credit_score" -> "firefly_calculate_credit_score";
- case "calculate_risk_score" -> "firefly_calculate_risk_score";
- case "payment_history_score" -> "firefly_payment_history_score";
-
- // Validation functions
- case "is_valid_credit_score" -> "firefly_is_valid_credit_score";
- case "is_valid_ssn" -> "firefly_is_valid_ssn";
- case "is_valid_account" -> "firefly_is_valid_account";
- case "is_valid_routing" -> "firefly_is_valid_routing";
- case "is_business_day" -> "firefly_is_business_day";
- case "age_meets_requirement" -> "firefly_age_meets_requirement";
- case "validate_email" -> "firefly_validate_email";
- case "validate_phone" -> "firefly_validate_phone";
-
- // Utility functions
- case "format_currency" -> "firefly_format_currency";
- case "format_percentage" -> "firefly_format_percentage";
- case "generate_account_number" -> "firefly_generate_account_number";
- case "generate_transaction_id" -> "firefly_generate_transaction_id";
- case "distance_between" -> "firefly_distance_between";
- case "is_valid" -> "firefly_is_valid";
- case "in_range" -> "firefly_in_range";
-
- // Logging/Auditing functions
- case "audit" -> "firefly_audit";
- case "audit_log" -> "firefly_audit_log";
- case "send_notification" -> "firefly_send_notification";
- case "log" -> "firefly_log";
-
- // Data Security functions
- case "encrypt" -> "firefly_encrypt";
- case "decrypt" -> "firefly_decrypt";
- case "mask_data" -> "firefly_mask_data";
-
- // REST API functions
- case "rest_get" -> "firefly_rest_get";
- case "rest_post" -> "firefly_rest_post";
- case "rest_put" -> "firefly_rest_put";
- case "rest_delete" -> "firefly_rest_delete";
- case "rest_patch" -> "firefly_rest_patch";
- case "rest_call" -> "firefly_rest_call";
-
- // JSON functions
- case "json_get", "json_path" -> "firefly_json_get";
- case "json_exists" -> "firefly_json_exists";
- case "json_size" -> "firefly_json_size";
- case "json_type" -> "firefly_json_type";
-
- // Default: prefix with firefly_
- default -> "firefly_" + functionName.toLowerCase();
- };
- }
-
- private String generateInteractiveMain(ASTRulesDSL rule, String functionName) {
- StringBuilder main = new StringBuilder();
-
- String finalFunctionName = functionName != null ?
- sanitizeFunctionName(functionName) :
- sanitizeFunctionName(rule.getName());
-
- main.append("\n\n");
- main.append("if __name__ == \"__main__\":\n");
- main.append(" \"\"\"\n");
- main.append(" Interactive execution mode for testing the compiled rule.\n");
- main.append(" \n");
- main.append(" Copyright 2024-2026 Firefly Software Foundation\n");
- main.append(" Licensed under the Apache License, Version 2.0\n");
- main.append(" Made with ❤️ by Firefly Software Foundation\n");
- main.append(" \"\"\"\n");
- main.append(" import sys\n");
- main.append(" import json\n");
- main.append(" \n");
- // Print header using runtime function
- main.append(" print_firefly_header(\"").append(rule.getName()).append("\"");
- if (rule.getDescription() != null) {
- main.append(", \"").append(rule.getDescription()).append("\"");
- } else {
- main.append(", None");
- }
- if (rule.getVersion() != null) {
- main.append(", \"").append(rule.getVersion()).append("\"");
- } else {
- main.append(", None");
- }
- main.append(")\n");
- main.append(" \n");
-
- // Check for constants that need configuration
- if (rule.getConstants() != null && !rule.getConstants().isEmpty()) {
- main.append(" # Check for constants that need configuration\n");
- main.append(" constants_need_config = []\n");
- for (ASTRulesDSL.ASTConstantDefinition constant : rule.getConstants()) {
- main.append(" if constants.get('").append(constant.getCode()).append("') is None:\n");
- main.append(" constants_need_config.append('").append(constant.getCode()).append("')\n");
- }
- main.append(" \n");
- main.append(" # Configure constants using runtime function\n");
- main.append(" constants_values = configure_constants_interactively(constants_need_config)\n");
- main.append(" constants.update(constants_values)\n");
- main.append(" \n");
- }
-
- // Interactive input collection
- main.append(" # Collect input variables\n");
- main.append(" context = {}\n");
- main.append(" \n");
-
- // Generate input definitions for the runtime
- if (rule.getInput() != null && !rule.getInput().isEmpty()) {
- main.append(" input_definitions = {\n");
- for (Map.Entry input : rule.getInput().entrySet()) {
- String inputType = mapInputType(input.getValue());
- main.append(" '").append(input.getKey()).append("': '").append(inputType).append("',\n");
- }
- main.append(" }\n");
- main.append(" context = collect_inputs(input_definitions)\n");
- } else {
- main.append(" context = {}\n");
- main.append(" print(\"ℹ️ No input variables required for this rule.\")\n");
- }
-
- main.append(" \n");
- main.append(" print(\"\\n🚀 Executing rule...\")\n");
- main.append(" print(\"-\" * 40)\n");
- main.append(" \n");
- main.append(" try:\n");
- main.append(" # Execute the rule\n");
- main.append(" result = ").append(finalFunctionName).append("(context)\n");
- main.append(" print_execution_results(result)\n");
- main.append(" except Exception as e:\n");
- main.append(" print(f\"❌ Error executing rule: {e}\")\n");
- main.append(" import traceback\n");
- main.append(" traceback.print_exc()\n");
- main.append(" sys.exit(1)\n");
- main.append(" \n");
- main.append(" print_firefly_footer()\n");
-
- return main.toString();
- }
-
-
-
- /**
- * Map input type from DSL to Python helper function type
- */
- private String mapInputType(String dslType) {
- if (dslType == null) return "text";
-
- String lowerType = dslType.toLowerCase();
- if (lowerType.contains("number") || lowerType.contains("int") ||
- lowerType.contains("float") || lowerType.contains("decimal")) {
- return "number";
- } else if (lowerType.contains("bool")) {
- return "boolean";
- } else {
- return "text";
- }
- }
-
-
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCompilationService.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCompilationService.java
deleted file mode 100644
index a8211cc..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCompilationService.java
+++ /dev/null
@@ -1,293 +0,0 @@
-package org.fireflyframework.rules.core.dsl.compiler;
-
-import org.fireflyframework.rules.core.dsl.model.ASTRulesDSL;
-import org.fireflyframework.rules.core.dsl.parser.ASTRulesDSLParser;
-import org.fireflyframework.rules.core.validation.YamlDslValidator;
-import org.fireflyframework.rules.interfaces.dtos.validation.ValidationResult;
-import org.fireflyframework.kernel.exception.FireflyException;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * Service for compiling YAML DSL rules to Python code.
- *
- * This service provides functionality to:
- * - Parse YAML DSL to AST
- * - Validate the AST
- * - Compile AST to Python code
- * - Cache compiled rules
- * - Manage compilation metadata
- *
- * @author Firefly Software Foundation
- * @since 1.0.0
- */
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class PythonCompilationService {
-
- private final ASTRulesDSLParser astRulesDSLParser;
- private final YamlDslValidator yamlDslValidator;
- private final PythonCodeGenerator pythonCodeGenerator;
-
- // Cache for compiled rules
- private final Map compilationCache = new ConcurrentHashMap<>();
-
- // Compilation statistics
- private long totalCompilations = 0;
- private long successfulCompilations = 0;
- private long failedCompilations = 0;
- private long cacheHits = 0;
-
- /**
- * Compile a YAML DSL rule to Python code.
- *
- * @param yamlDsl The YAML DSL rule definition
- * @param ruleName Optional rule name (will be extracted from DSL if not provided)
- * @param useCache Whether to use compilation cache
- * @return Compiled Python rule
- * @throws PythonCompilationException if compilation fails
- */
- public PythonCompiledRule compileRule(String yamlDsl, String ruleName, boolean useCache) {
- log.debug("Starting Python compilation for rule: {}", ruleName);
-
- totalCompilations++;
-
- try {
- // Generate cache key
- String cacheKey = generateCacheKey(yamlDsl, ruleName);
-
- // Check cache if enabled
- if (useCache && compilationCache.containsKey(cacheKey)) {
- log.debug("Cache hit for rule: {}", ruleName);
- cacheHits++;
- return compilationCache.get(cacheKey);
- }
-
- // Parse YAML DSL to AST
- @SuppressWarnings("deprecation")
- ASTRulesDSL astRule = astRulesDSLParser.parseRules(yamlDsl);
-
- // Validate the AST
- ValidationResult validationResult = yamlDslValidator.validate(yamlDsl);
- if (!validationResult.isValid()) {
- throw new PythonCompilationException(
- "DSL validation failed: " + validationResult.getErrors()
- );
- }
-
- // Generate Python code
- String finalRuleName = ruleName != null ? ruleName :
- (astRule.getName() != null ? astRule.getName() : "unnamed_rule");
- String pythonCode = pythonCodeGenerator.generatePythonFunction(astRule, finalRuleName);
-
- // Create compiled rule
- PythonCompiledRule compiledRule = createCompiledRule(astRule, pythonCode, ruleName);
-
- // Cache the result if enabled
- if (useCache) {
- compilationCache.put(cacheKey, compiledRule);
- }
-
- successfulCompilations++;
- log.info("Successfully compiled rule '{}' to Python", compiledRule.getRuleName());
-
- return compiledRule;
-
- } catch (Exception e) {
- failedCompilations++;
- log.error("Failed to compile rule '{}' to Python: {}", ruleName, e.getMessage(), e);
- throw new PythonCompilationException("Compilation failed: " + e.getMessage(), e);
- }
- }
-
- /**
- * Compile a YAML DSL rule to Python code with default settings.
- *
- * @param yamlDsl The YAML DSL rule definition
- * @return Compiled Python rule
- */
- public PythonCompiledRule compileRule(String yamlDsl) {
- return compileRule(yamlDsl, null, true);
- }
-
- /**
- * Compile a YAML DSL rule to Python code with specified rule name.
- *
- * @param yamlDsl The YAML DSL rule definition
- * @param ruleName The rule name
- * @return Compiled Python rule
- */
- public PythonCompiledRule compileRule(String yamlDsl, String ruleName) {
- return compileRule(yamlDsl, ruleName, true);
- }
-
- /**
- * Batch compile multiple YAML DSL rules to Python code.
- *
- * @param rules Map of rule names to YAML DSL definitions
- * @param useCache Whether to use compilation cache
- * @return Map of rule names to compiled Python rules
- */
- public Map compileRules(Map rules, boolean useCache) {
- log.info("Starting batch compilation of {} rules", rules.size());
-
- Map compiledRules = new ConcurrentHashMap<>();
-
- rules.entrySet().parallelStream().forEach(entry -> {
- try {
- PythonCompiledRule compiledRule = compileRule(entry.getValue(), entry.getKey(), useCache);
- compiledRules.put(entry.getKey(), compiledRule);
- } catch (Exception e) {
- log.error("Failed to compile rule '{}': {}", entry.getKey(), e.getMessage());
- // Continue with other rules
- }
- });
-
- log.info("Batch compilation completed. Successfully compiled {}/{} rules",
- compiledRules.size(), rules.size());
-
- return compiledRules;
- }
-
- /**
- * Clear the compilation cache.
- */
- public void clearCache() {
- compilationCache.clear();
- log.info("Compilation cache cleared");
- }
-
- /**
- * Get compilation statistics.
- *
- * @return Map containing compilation statistics
- */
- public Map getCompilationStats() {
- return Map.of(
- "totalCompilations", totalCompilations,
- "successfulCompilations", successfulCompilations,
- "failedCompilations", failedCompilations,
- "cacheHits", cacheHits,
- "cacheSize", compilationCache.size(),
- "successRate", totalCompilations > 0 ? (double) successfulCompilations / totalCompilations : 0.0,
- "cacheHitRate", totalCompilations > 0 ? (double) cacheHits / totalCompilations : 0.0
- );
- }
-
- /**
- * Get cached rule by cache key.
- *
- * @param yamlDsl The YAML DSL
- * @param ruleName The rule name
- * @return Cached compiled rule or null if not found
- */
- public PythonCompiledRule getCachedRule(String yamlDsl, String ruleName) {
- String cacheKey = generateCacheKey(yamlDsl, ruleName);
- return compilationCache.get(cacheKey);
- }
-
- /**
- * Check if a rule is cached.
- *
- * @param yamlDsl The YAML DSL
- * @param ruleName The rule name
- * @return True if the rule is cached
- */
- public boolean isRuleCached(String yamlDsl, String ruleName) {
- String cacheKey = generateCacheKey(yamlDsl, ruleName);
- return compilationCache.containsKey(cacheKey);
- }
-
- /**
- * Remove a specific rule from cache.
- *
- * @param yamlDsl The YAML DSL
- * @param ruleName The rule name
- * @return True if the rule was removed from cache
- */
- public boolean removeCachedRule(String yamlDsl, String ruleName) {
- String cacheKey = generateCacheKey(yamlDsl, ruleName);
- return compilationCache.remove(cacheKey) != null;
- }
-
- /**
- * Remove cached rules by rule name pattern.
- * This method removes all cached rules that match the given rule name.
- * Useful for DELETE endpoints that should not have request bodies.
- *
- * @param ruleName The rule name to remove from cache
- * @return True if at least one rule was removed from cache
- */
- public boolean removeCachedRuleByName(String ruleName) {
- boolean removed = false;
-
- // Find all cache keys that start with the rule name
- for (String cacheKey : compilationCache.keySet()) {
- if (cacheKey.startsWith(ruleName + "_")) {
- if (compilationCache.remove(cacheKey) != null) {
- removed = true;
- log.debug("Removed cached rule with key: {}", cacheKey);
- }
- }
- }
-
- if (removed) {
- log.info("Removed cached rules for rule name: {}", ruleName);
- } else {
- log.debug("No cached rules found for rule name: {}", ruleName);
- }
-
- return removed;
- }
-
- // Private helper methods
-
- private String generateCacheKey(String yamlDsl, String ruleName) {
- // Use a combination of rule name and DSL hash for cache key
- int dslHash = yamlDsl.hashCode();
- String name = ruleName != null ? ruleName : "unnamed";
- return name + "_" + dslHash;
- }
-
- private PythonCompiledRule createCompiledRule(ASTRulesDSL astRule, String pythonCode, String ruleName) {
- String finalRuleName = ruleName != null ? ruleName :
- (astRule.getName() != null ? astRule.getName() : "unnamed_rule");
-
- // Use description if available, otherwise use name
- String description = astRule.getDescription() != null && !astRule.getDescription().trim().isEmpty()
- ? astRule.getDescription()
- : astRule.getName();
-
- return PythonCompiledRule.builder()
- .ruleName(finalRuleName)
- .description(description)
- .version(astRule.getVersion() != null ? astRule.getVersion() : "1.0.0")
- .pythonCode(pythonCode)
- .functionName(pythonCodeGenerator.sanitizeFunctionName(finalRuleName))
- .inputVariables(pythonCodeGenerator.extractInputVariables(astRule))
- .outputVariables(pythonCodeGenerator.extractOutputVariables(astRule))
- .compiledAt(LocalDateTime.now())
- .sourceHash(String.valueOf(astRule.hashCode()))
- .build();
- }
-
- /**
- * Exception thrown when Python compilation fails.
- */
- public static class PythonCompilationException extends FireflyException {
- public PythonCompilationException(String message) {
- super(message);
- }
-
- public PythonCompilationException(String message, Throwable cause) {
- super(message, cause);
- }
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCompiledRule.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCompiledRule.java
deleted file mode 100644
index eef00c6..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCompiledRule.java
+++ /dev/null
@@ -1,161 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.compiler;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Represents a compiled Python rule with all necessary metadata and code.
- * This is the result of compiling a DSL rule to executable Python code.
- */
-@Data
-@Builder
-@AllArgsConstructor
-@NoArgsConstructor
-public class PythonCompiledRule {
-
- /**
- * Name of the original rule
- */
- private String ruleName;
-
- /**
- * Description of the rule
- */
- private String description;
-
- /**
- * Version of the rule
- */
- private String version;
-
- /**
- * Generated Python code
- */
- private String pythonCode;
-
- /**
- * List of input variable names expected by the rule
- */
- private List inputVariables;
-
- /**
- * Map of output variable names to their types
- */
- private Map outputVariables;
-
- /**
- * Timestamp when the rule was compiled
- */
- private LocalDateTime compiledAt;
-
- /**
- * Hash of the original DSL for cache invalidation
- */
- private String sourceHash;
-
- /**
- * Python function name (sanitized version of rule name)
- */
- private String functionName;
-
- /**
- * Required Python runtime dependencies
- */
- private List dependencies;
-
- /**
- * Compilation metadata
- */
- private CompilationMetadata metadata;
-
- public PythonCompiledRule(String ruleName, String description, String version,
- String pythonCode, List inputVariables,
- Map outputVariables) {
- this.ruleName = ruleName;
- this.description = description;
- this.version = version;
- this.pythonCode = pythonCode;
- this.inputVariables = inputVariables;
- this.outputVariables = outputVariables;
- this.compiledAt = LocalDateTime.now();
- this.functionName = sanitizeFunctionName(ruleName);
- this.dependencies = List.of("firefly_runtime");
- }
-
- /**
- * Get the main function name for this compiled rule
- */
- public String getMainFunctionName() {
- return functionName != null ? functionName : sanitizeFunctionName(ruleName);
- }
-
- /**
- * Check if the compiled rule is valid and executable
- */
- public boolean isValid() {
- return pythonCode != null && !pythonCode.trim().isEmpty() &&
- ruleName != null && !ruleName.trim().isEmpty();
- }
-
- /**
- * Get the size of the generated Python code in bytes
- */
- public int getCodeSize() {
- return pythonCode != null ? pythonCode.getBytes().length : 0;
- }
-
- /**
- * Sanitize rule name to create a valid Python function name
- */
- private String sanitizeFunctionName(String name) {
- if (name == null) return "unnamed_rule";
-
- String sanitized = name.toLowerCase()
- .replaceAll("[^a-zA-Z0-9_]", "_")
- .replaceAll("_{2,}", "_")
- .replaceAll("^_|_$", "");
-
- // Add underscore prefix if starts with number
- if (sanitized.matches("^[0-9].*")) {
- sanitized = "_" + sanitized;
- }
-
- return sanitized;
- }
-
- /**
- * Compilation metadata for tracking and debugging
- */
- @Data
- @AllArgsConstructor
- @NoArgsConstructor
- public static class CompilationMetadata {
- private String compilerVersion;
- private long compilationTimeMs;
- private int astNodeCount;
- private List warnings;
- private Map optimizations;
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java
index 43be71b..4ac0fed 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java
@@ -23,11 +23,13 @@
import org.fireflyframework.rules.core.dsl.condition.ExpressionCondition;
import org.fireflyframework.rules.core.dsl.condition.LogicalCondition;
import org.fireflyframework.rules.core.dsl.expression.*;
+import org.fireflyframework.rules.core.dsl.function.CustomFunctionRegistry;
import org.fireflyframework.rules.core.dsl.model.ASTRulesDSL;
import org.fireflyframework.rules.core.dsl.parser.ASTRulesDSLParser;
import org.fireflyframework.rules.core.dsl.visitor.ActionExecutor;
import org.fireflyframework.rules.core.dsl.visitor.EvaluationContext;
import org.fireflyframework.rules.core.dsl.visitor.ExpressionEvaluator;
+import org.fireflyframework.rules.core.observability.RuleEngineMetrics;
import org.fireflyframework.rules.core.services.ConstantService;
import org.fireflyframework.rules.core.services.JsonPathService;
import org.fireflyframework.rules.core.services.RestCallService;
@@ -39,6 +41,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
import java.util.*;
import java.util.stream.Collectors;
@@ -55,40 +58,101 @@ public class ASTRulesEvaluationEngine {
private final ConstantService constantService;
private final RestCallService restCallService;
private final JsonPathService jsonPathService;
+ private final CustomFunctionRegistry customFunctions;
+ private final RuleEngineMetrics metrics;
+ private final org.fireflyframework.rules.core.dsl.function.RuleInvoker ruleInvoker;
/**
- * Primary constructor for Spring dependency injection
- * RestCallService and JsonPathService are optional and will use defaults if not provided
+ * Primary constructor for Spring dependency injection.
+ *
+ * {@code restCallService}, {@code jsonPathService}, {@code customFunctions}, and
+ * {@code metrics} are optional. When absent, REST/JSON built-ins fall back to internal
+ * default implementations, no user-registered functions are available, and no metrics
+ * are recorded.
*/
@Autowired
public ASTRulesEvaluationEngine(ASTRulesDSLParser parser,
ConstantService constantService,
@Autowired(required = false) RestCallService restCallService,
- @Autowired(required = false) JsonPathService jsonPathService) {
+ @Autowired(required = false) JsonPathService jsonPathService,
+ @Autowired(required = false) CustomFunctionRegistry customFunctions,
+ @Autowired(required = false) RuleEngineMetrics metrics,
+ @Autowired(required = false) org.fireflyframework.rules.core.dsl.function.RuleInvoker ruleInvoker) {
this.parser = Objects.requireNonNull(parser, "ASTRulesDSLParser cannot be null");
this.constantService = Objects.requireNonNull(constantService, "ConstantService cannot be null");
this.restCallService = restCallService != null ? restCallService : new RestCallServiceImpl();
this.jsonPathService = jsonPathService != null ? jsonPathService : new JsonPathServiceImpl();
+ this.customFunctions = customFunctions;
+ this.metrics = metrics;
+ this.ruleInvoker = ruleInvoker;
}
/**
- * Constructor with default REST and JSON services (for testing)
+ * Test-friendly constructor with default REST/JSON services and no custom function registry.
*/
public ASTRulesEvaluationEngine(ASTRulesDSLParser parser, ConstantService constantService) {
this.parser = Objects.requireNonNull(parser, "ASTRulesDSLParser cannot be null");
this.constantService = Objects.requireNonNull(constantService, "ConstantService cannot be null");
this.restCallService = new RestCallServiceImpl();
this.jsonPathService = new JsonPathServiceImpl();
+ this.customFunctions = null;
+ this.metrics = null;
+ this.ruleInvoker = null;
+ }
+
+ /**
+ * Test-friendly 4-arg constructor (parser, constantService, restCallService, jsonPathService)
+ * preserved for backward compatibility with existing tests. Delegates to the wider form with
+ * {@code null} for the optional collaborators.
+ */
+ public ASTRulesEvaluationEngine(ASTRulesDSLParser parser,
+ ConstantService constantService,
+ RestCallService restCallService,
+ JsonPathService jsonPathService) {
+ this(parser, constantService, restCallService, jsonPathService, null, null, null);
+ }
+
+ /**
+ * Test-friendly 5-arg constructor (parser, constantService, restCallService, jsonPathService,
+ * customFunctions) preserved for backward compatibility with existing tests.
+ */
+ public ASTRulesEvaluationEngine(ASTRulesDSLParser parser,
+ ConstantService constantService,
+ RestCallService restCallService,
+ JsonPathService jsonPathService,
+ CustomFunctionRegistry customFunctions) {
+ this(parser, constantService, restCallService, jsonPathService, customFunctions, null, null);
+ }
+
+ /**
+ * Test-friendly 6-arg constructor preserved for backward compatibility.
+ */
+ public ASTRulesEvaluationEngine(ASTRulesDSLParser parser,
+ ConstantService constantService,
+ RestCallService restCallService,
+ JsonPathService jsonPathService,
+ CustomFunctionRegistry customFunctions,
+ RuleEngineMetrics metrics) {
+ this(parser, constantService, restCallService, jsonPathService, customFunctions, metrics, null);
}
/**
- * Evaluate rules against the provided input data
+ * Evaluate rules against the provided input data.
+ *
+ * The visitor-based evaluator is synchronous and may transitively block (e.g., built-in
+ * REST/JSON functions). To avoid stalling the Netty event loop, the evaluation step is
+ * scheduled on {@code Schedulers.boundedElastic()} which is designed for blocking work.
*/
public Mono evaluateRulesReactive(String rulesDefinition, Map inputData) {
long startTime = System.currentTimeMillis();
- return parser.parseRulesReactive(rulesDefinition)
+ Mono pipeline = parser.parseRulesReactive(rulesDefinition)
+ .doOnSuccess(dsl -> { if (metrics != null) metrics.recordCompilation(true); })
+ .doOnError(e -> { if (metrics != null) metrics.recordCompilation(false); })
.flatMap(rulesDSL -> createEvaluationContextReactive(rulesDSL, inputData)
- .map(context -> evaluateRules(rulesDSL, context)))
+ .flatMap(context -> applyTimeout(rulesDSL,
+ Mono.fromCallable(() -> evaluateRules(rulesDSL, context))
+ .subscribeOn(Schedulers.boundedElastic())))
+ .doOnSuccess(result -> recordEvaluationOutcome(rulesDSL, result)))
.onErrorResume(error -> {
long executionTime = System.currentTimeMillis() - startTime;
JsonLogger.error(log, "Rules evaluation failed", error);
@@ -103,6 +167,42 @@ public Mono evaluateRulesReactive(String rulesDefiniti
.executionTimeMs(executionTime)
.build());
});
+ return pipeline;
+ }
+
+ /**
+ * Wrap evaluation in a Reactor timeout if the rule declared one. Timeout failures map
+ * to a clean fail-loud result rather than an uncaught TimeoutException.
+ */
+ private Mono applyTimeout(ASTRulesDSL rulesDSL, Mono mono) {
+ if (rulesDSL.getTimeoutMs() == null || rulesDSL.getTimeoutMs() <= 0) return mono;
+ java.time.Duration limit = java.time.Duration.ofMillis(rulesDSL.getTimeoutMs());
+ return mono.timeout(limit, Mono.just(ASTRulesEvaluationResult.builder()
+ .success(false)
+ .error("Rule '" + (rulesDSL.getName() != null ? rulesDSL.getName() : "anonymous")
+ + "' exceeded its declared timeout of " + rulesDSL.getTimeoutMs() + "ms")
+ .build()));
+ }
+
+ /**
+ * Record per-rule metrics for a completed evaluation. The rule id is taken from the
+ * rule's {@code name} (or {@code "anonymous"} if not declared). No-op if no
+ * {@link RuleEngineMetrics} bean is wired in.
+ */
+ private void recordEvaluationOutcome(ASTRulesDSL rulesDSL, ASTRulesEvaluationResult result) {
+ if (metrics == null) return;
+ String ruleId = rulesDSL.getName() != null && !rulesDSL.getName().isBlank()
+ ? rulesDSL.getName() : "anonymous";
+ if (!result.isSuccess()) {
+ metrics.recordUnmatched(ruleId);
+ } else if (result.isCircuitBreakerTriggered()) {
+ metrics.recordUnmatched(ruleId);
+ } else if (!result.isConditionResult()) {
+ metrics.recordUnmatched(ruleId);
+ }
+ // Note: matched/error metrics for the success path are recorded by the timer wrapper
+ // when the engine is invoked via timedEvaluation. For direct evaluateRulesReactive
+ // callers we record only the unmatched case here to avoid double-counting.
}
/**
@@ -113,12 +213,14 @@ public ASTRulesEvaluationResult evaluateRules(String rulesDefinition, Map evaluateRulesReactive(ASTRulesDSL rulesDSL, Map inputData) {
long startTime = System.currentTimeMillis();
return createEvaluationContextReactive(rulesDSL, inputData)
- .map(context -> evaluateRules(rulesDSL, context))
+ .flatMap(context -> Mono.fromCallable(() -> evaluateRules(rulesDSL, context))
+ .subscribeOn(Schedulers.boundedElastic()))
.onErrorResume(error -> {
long executionTime = System.currentTimeMillis() - startTime;
JsonLogger.error(log, "Rules evaluation failed", error);
@@ -154,9 +256,13 @@ private ASTRulesEvaluationResult evaluateRules(ASTRulesDSL rulesDSL, EvaluationC
boolean conditionResult = false; // Declare outside try block for circuit breaker catch
try {
-
+
+ // DMN-style decision table takes precedence when present.
+ if (rulesDSL.isDecisionTable()) {
+ conditionResult = evaluateDecisionTable(rulesDSL.getDecisionTable(), context);
+ }
// Handle simplified syntax (when/then/else)
- if (rulesDSL.isSimpleSyntax()) {
+ else if (rulesDSL.isSimpleSyntax()) {
conditionResult = evaluateConditions(rulesDSL.getWhenConditions(), context);
if (conditionResult && rulesDSL.getThenActions() != null) {
@@ -239,21 +345,25 @@ private boolean evaluateConditions(List conditions, EvaluationContext
for (int i = 0; i < conditions.size(); i++) {
Condition condition = conditions.get(i);
+ ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService, customFunctions, ruleInvoker);
+ Object result;
try {
- ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService);
- Object result = condition.accept(evaluator);
- boolean boolResult = toBoolean(result);
-
- JsonLogger.info(log, context.getOperationId(),
- String.format("Condition %d evaluation: %s = %s", i + 1, condition.toDebugString(), boolResult));
-
- if (!boolResult) {
- JsonLogger.info(log, context.getOperationId(), "Condition failed - short-circuiting evaluation");
- return false;
- }
- } catch (Exception e) {
- String operationId = context.getOperationId();
- JsonLogger.error(log, operationId, "Error evaluating condition: " + condition.toDebugString(), e);
+ result = condition.accept(evaluator);
+ } catch (RuntimeException e) {
+ // Propagate so the outer evaluateRules() handler reports success=false
+ // with the real cause; swallowing here would silently flip rules to the
+ // else branch and mask authoring or data bugs.
+ throw new RuleEvaluationException(
+ "Failed to evaluate condition " + (i + 1) + " (" + condition.toDebugString() + "): "
+ + e.getMessage(), e);
+ }
+
+ boolean boolResult = toBoolean(result);
+ JsonLogger.info(log, context.getOperationId(),
+ String.format("Condition %d evaluation: %s = %s", i + 1, condition.toDebugString(), boolResult));
+
+ if (!boolResult) {
+ JsonLogger.info(log, context.getOperationId(), "Condition failed - short-circuiting evaluation");
return false;
}
}
@@ -278,25 +388,100 @@ private void executeActions(List actions, EvaluationContext context) {
JsonLogger.info(log, context.getOperationId(),
String.format("Executing action %d: %s", i + 1, action.toDebugString()));
- ActionExecutor executor = new ActionExecutor(context, restCallService, jsonPathService);
+ ActionExecutor executor = new ActionExecutor(context, restCallService, jsonPathService, customFunctions, ruleInvoker);
action.accept(executor);
JsonLogger.info(log, context.getOperationId(),
String.format("Action %d completed successfully", i + 1));
} catch (org.fireflyframework.rules.core.dsl.exception.CircuitBreakerException e) {
- // Circuit breaker is a controlled stop, not an error
JsonLogger.info(log, context.getOperationId(),
"Circuit breaker triggered: " + e.getCircuitBreakerMessage() + " - stopping execution");
- // Re-throw to stop execution immediately
throw e;
- } catch (Exception e) {
- String operationId = context.getOperationId();
- JsonLogger.error(log, operationId, "Error executing action: " + action.toDebugString(), e);
+ } catch (RuntimeException e) {
+ // Fail-fast: the previous swallow-and-continue policy let later actions
+ // read variables that the failing action never set, masking the real cause.
+ // The outer evaluateRules() catch converts this into success=false with the
+ // original message preserved.
+ throw new RuleEvaluationException(
+ "Failed to execute action " + (i + 1) + " (" + action.toDebugString() + "): "
+ + e.getMessage(), e);
}
}
JsonLogger.info(log, context.getOperationId(), "All actions completed");
}
+ /**
+ * Evaluate a DMN-style decision table. Walks each row, tests the row's `when` predicates,
+ * and -- depending on the hit policy -- collects, picks first, or enforces uniqueness of
+ * matching rows' outputs. The resulting outputs are written into the context's computed
+ * variables and become part of the rule's output map.
+ */
+ private boolean evaluateDecisionTable(ASTRulesDSL.ASTDecisionTable table, EvaluationContext context) {
+ if (table == null || table.getRows() == null || table.getRows().isEmpty()) {
+ return false;
+ }
+ java.util.List matches = new java.util.ArrayList<>();
+ ASTRulesDSL.ASTDecisionRow fallback = null;
+ for (ASTRulesDSL.ASTDecisionRow row : table.getRows()) {
+ if (row.isOtherwise()) { fallback = row; continue; }
+ boolean ok = row.getWhen() == null || row.getWhen().isEmpty()
+ || evaluateConditions(row.getWhen(), context);
+ if (ok) matches.add(row);
+ }
+ if (matches.isEmpty() && fallback != null) matches.add(fallback);
+ if (matches.isEmpty()) return false;
+
+ ASTRulesDSL.HitPolicy policy = table.getHitPolicy() != null ? table.getHitPolicy() : ASTRulesDSL.HitPolicy.FIRST;
+ ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService, customFunctions, ruleInvoker);
+ switch (policy) {
+ case FIRST, ANY -> applyDecisionRow(matches.get(0), evaluator, context);
+ case UNIQUE -> {
+ if (matches.size() > 1) {
+ throw new RuleEvaluationException(
+ "Decision table hit_policy=UNIQUE expects exactly one matching row but " + matches.size() + " matched");
+ }
+ applyDecisionRow(matches.get(0), evaluator, context);
+ }
+ case COLLECT -> {
+ java.util.Map> grouped = new java.util.LinkedHashMap<>();
+ for (ASTRulesDSL.ASTDecisionRow row : matches) {
+ if (row.getOutputs() == null) continue;
+ for (var e : row.getOutputs().entrySet()) {
+ Object val = evaluateOutputCell(e.getValue(), evaluator);
+ grouped.computeIfAbsent(e.getKey(), k -> new java.util.ArrayList<>()).add(val);
+ }
+ }
+ grouped.forEach(context::setComputedVariable);
+ }
+ }
+ return true;
+ }
+
+ private void applyDecisionRow(ASTRulesDSL.ASTDecisionRow row, ExpressionEvaluator evaluator, EvaluationContext context) {
+ if (row.getOutputs() == null) return;
+ for (var e : row.getOutputs().entrySet()) {
+ context.setComputedVariable(e.getKey(), evaluateOutputCell(e.getValue(), evaluator));
+ }
+ }
+
+ private Object evaluateOutputCell(Object raw, ExpressionEvaluator evaluator) {
+ // Numbers, booleans, lists, maps pass through unchanged.
+ // Strings default to literals (DMN-style); prefix with '=' to mark an expression
+ // that should be parsed and evaluated against the current context.
+ if (!(raw instanceof String s)) return raw;
+ String trimmed = s.trim();
+ if (trimmed.startsWith("=")) {
+ String expr = trimmed.substring(1).trim();
+ try {
+ return parser.getDslParser().parseExpression(expr).accept(evaluator);
+ } catch (RuntimeException ex) {
+ throw new RuleEvaluationException(
+ "Decision table: failed to evaluate expression '" + expr + "': " + ex.getMessage(), ex);
+ }
+ }
+ return s;
+ }
+
/**
* Evaluate multiple rules using AST
*/
@@ -354,29 +539,28 @@ private boolean evaluateConditionalBlock(ASTRulesDSL.ASTConditionalBlock conditi
if (conditionalBlock == null || conditionalBlock.getIfCondition() == null) {
return false;
}
-
+
+ ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService, customFunctions, ruleInvoker);
+ Object result;
try {
- // Evaluate the condition using AST
- ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService);
- Object result = conditionalBlock.getIfCondition().accept(evaluator);
- boolean conditionResult = toBoolean(result);
-
- // Execute appropriate action block
- ASTRulesDSL.ASTActionBlock actionBlock = conditionResult ?
- conditionalBlock.getThenBlock() :
- conditionalBlock.getElseBlock();
-
- if (actionBlock != null) {
- executeActionBlock(actionBlock, context);
- }
-
- return conditionResult;
-
- } catch (Exception e) {
- String operationId = context.getOperationId();
- JsonLogger.error(log, operationId, "Error evaluating conditional block", e);
- return false;
+ result = conditionalBlock.getIfCondition().accept(evaluator);
+ } catch (RuntimeException e) {
+ // Propagate so the rule reports the real cause instead of silently falling
+ // through to the else branch.
+ throw new RuleEvaluationException(
+ "Failed to evaluate conditional block 'if' clause: " + e.getMessage(), e);
+ }
+ boolean conditionResult = toBoolean(result);
+
+ ASTRulesDSL.ASTActionBlock actionBlock = conditionResult ?
+ conditionalBlock.getThenBlock() :
+ conditionalBlock.getElseBlock();
+
+ if (actionBlock != null) {
+ executeActionBlock(actionBlock, context);
}
+
+ return conditionResult;
}
/**
@@ -395,7 +579,6 @@ private void executeActionBlock(ASTRulesDSL.ASTActionBlock actionBlock, Evaluati
executeActions(actionBlock.getActions(), context);
}
- // Handle nested conditional blocks - this was the TODO that's now implemented!
if (actionBlock.getNestedConditions() != null) {
evaluateConditionalBlock(actionBlock.getNestedConditions(), context);
}
@@ -407,7 +590,13 @@ private void executeActionBlock(ASTRulesDSL.ASTActionBlock actionBlock, Evaluati
private Mono createEvaluationContextReactive(ASTRulesDSL rulesDSL, Map inputData) {
JsonLogger.info(log, "createEvaluationContextReactive called for rule: " + rulesDSL.getName());
String operationId = UUID.randomUUID().toString();
- EvaluationContext context = new EvaluationContext(operationId, inputData != null ? inputData : new HashMap<>());
+ // Apply declared input defaults for any variable the caller omitted. Caller-provided
+ // values always win; this only fills the gaps so rule authors can rely on the
+ // declared default appearing in the context.
+ Map effectiveInputs = new HashMap<>();
+ if (rulesDSL.getInputDefaults() != null) effectiveInputs.putAll(rulesDSL.getInputDefaults());
+ if (inputData != null) effectiveInputs.putAll(inputData);
+ EvaluationContext context = new EvaluationContext(operationId, effectiveInputs);
// Log the start of rule evaluation early so it appears even if constants loading fails
JsonLogger.info(log, operationId, "Starting AST-based rule evaluation: " + rulesDSL.getName());
@@ -666,12 +855,6 @@ public Void visitExpressionCondition(ExpressionCondition node) {
return null;
}
- @Override
- public Void visitAssignmentAction(AssignmentAction node) {
- node.getValue().accept(this);
- return null;
- }
-
@Override
public Void visitCalculateAction(CalculateAction node) {
node.getExpression().accept(this);
@@ -737,16 +920,6 @@ public Void visitFunctionCallExpression(FunctionCallExpression node) {
return null;
}
- @Override
- public Void visitArithmeticExpression(ArithmeticExpression node) {
- if (node.getOperands() != null) {
- node.getOperands().forEach(operand -> operand.accept(this));
- }
- return null;
- }
-
-
-
@Override
public Void visitArithmeticAction(ArithmeticAction node) {
if (node.getValue() != null) {
@@ -819,32 +992,5 @@ public Void visitDoWhileAction(DoWhileAction node) {
return null;
}
-
- @Override
- public Void visitJsonPathExpression(JsonPathExpression node) {
- // Visit the source expression to collect any variable references
- if (node.getSourceExpression() != null) {
- node.getSourceExpression().accept(this);
- }
- return null;
- }
-
- @Override
- public Void visitRestCallExpression(RestCallExpression node) {
- // Visit all expressions to collect any variable references
- if (node.getUrlExpression() != null) {
- node.getUrlExpression().accept(this);
- }
- if (node.getBodyExpression() != null) {
- node.getBodyExpression().accept(this);
- }
- if (node.getHeadersExpression() != null) {
- node.getHeadersExpression().accept(this);
- }
- if (node.getTimeoutExpression() != null) {
- node.getTimeoutExpression().accept(this);
- }
- return null;
- }
}
}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/RuleEvaluationException.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/RuleEvaluationException.java
new file mode 100644
index 0000000..dd6310a
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/RuleEvaluationException.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.fireflyframework.rules.core.dsl.evaluation;
+
+/**
+ * Raised when an action or condition fails during rule evaluation.
+ *
+ * The engine wraps the underlying failure (e.g. unknown function, type-coercion error,
+ * division by zero, missing variable) with the index of the offending action or condition
+ * and its source debug string so the outer evaluator can report a precise
+ * {@code success=false} result instead of silently flipping to the else branch.
+ */
+public class RuleEvaluationException extends RuntimeException {
+
+ public RuleEvaluationException(String message) {
+ super(message);
+ }
+
+ public RuleEvaluationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java
index 38f38ec..55bc374 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java
@@ -55,6 +55,10 @@ public ASTException(String message, SourceLocation location) {
public ASTException(String message) {
this(message, null, "AST_GENERIC", List.of(), null);
}
+
+ public ASTException(String message, Throwable cause) {
+ this(message, null, "AST_GENERIC", List.of(), cause);
+ }
/**
* Get a detailed error message with location and suggestions
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticExpression.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticExpression.java
deleted file mode 100644
index 591b3b0..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticExpression.java
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-import java.util.List;
-import java.util.stream.Collectors;
-
-/**
- * Represents an arithmetic expression with multiple operands.
- * Examples: add(a, b, c), multiply(x, y), max(values...)
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class ArithmeticExpression extends Expression {
-
- /**
- * The arithmetic operation to perform
- */
- private ArithmeticOperation operation;
-
- /**
- * Operands for the arithmetic operation
- */
- private List operands;
-
- public ArithmeticExpression(SourceLocation location, ArithmeticOperation operation, List operands) {
- super(location);
- this.operation = operation;
- this.operands = operands;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitArithmeticExpression(this);
- }
-
- @Override
- public ExpressionType getExpressionType() {
- return ExpressionType.NUMBER;
- }
-
- @Override
- public boolean isConstant() {
- return operands != null && operands.stream().allMatch(Expression::isConstant);
- }
-
- @Override
- public boolean hasVariableReferences() {
- return operands != null && operands.stream().anyMatch(Expression::hasVariableReferences);
- }
-
- @Override
- public String toDebugString() {
- if (operands == null || operands.isEmpty()) {
- return operation.getSymbol() + "()";
- }
-
- String args = operands.stream()
- .map(Expression::toDebugString)
- .collect(Collectors.joining(", "));
-
- return operation.getSymbol() + "(" + args + ")";
- }
-
- /**
- * Get the number of operands
- */
- public int getOperandCount() {
- return operands != null ? operands.size() : 0;
- }
-
- /**
- * Get a specific operand by index
- */
- public Expression getOperand(int index) {
- if (operands == null || index < 0 || index >= operands.size()) {
- throw new IndexOutOfBoundsException("Operand index out of bounds: " + index);
- }
- return operands.get(index);
- }
-}
-
-
-
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticOperation.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticOperation.java
deleted file mode 100644
index ec1ecb9..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticOperation.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-/**
- * Enumeration of arithmetic operations.
- */
-public enum ArithmeticOperation {
- // Basic arithmetic
- ADD("add", "+", 6),
- SUBTRACT("subtract", "-", 6),
- MULTIPLY("multiply", "*", 7),
- DIVIDE("divide", "/", 7),
- MODULO("modulo", "%", 7),
- POWER("power", "^", 8),
-
- // Mathematical functions
- ABS("abs", "abs", 9),
- MIN("min", "min", 9),
- MAX("max", "max", 9),
- ROUND("round", "round", 9),
- FLOOR("floor", "floor", 9),
- CEIL("ceil", "ceil", 9),
- SQRT("sqrt", "sqrt", 9),
- SUM("sum", "sum", 9),
- AVERAGE("average", "avg", 9);
-
- private final String name;
- private final String symbol;
- private final int precedence;
-
- ArithmeticOperation(String name, String symbol, int precedence) {
- this.name = name;
- this.symbol = symbol;
- this.precedence = precedence;
- }
-
- public String getName() {
- return name;
- }
-
- public String getSymbol() {
- return symbol;
- }
-
- public int getPrecedence() {
- return precedence;
- }
-
- public int getMinOperands() {
- return switch (this) {
- case ABS, ROUND, FLOOR, CEIL, SQRT -> 1;
- case MIN, MAX -> 2;
- case SUM, AVERAGE -> 1; // Can take 1 or more
- default -> 2; // Binary operations
- };
- }
-
- public int getMaxOperands() {
- return switch (this) {
- case ABS, ROUND, FLOOR, CEIL, SQRT -> 1;
- case SUM, AVERAGE -> Integer.MAX_VALUE; // Can take any number
- default -> 2; // Binary operations
- };
- }
-
- public static ArithmeticOperation fromSymbol(String symbol) {
- for (ArithmeticOperation op : values()) {
- if (op.symbol.equals(symbol)) {
- return op;
- }
- }
- throw new IllegalArgumentException("Unknown arithmetic operation: " + symbol);
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/JsonPathExpression.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/JsonPathExpression.java
deleted file mode 100644
index c3779c1..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/JsonPathExpression.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * Represents a JSON path expression for accessing nested JSON values.
- * Examples:
- * - user.name (access name property of user object)
- * - users[0].email (access email of first user in array)
- * - response.data.items[2].price (deep nested access)
- * - todos.length (get array length)
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class JsonPathExpression extends Expression {
-
- /**
- * The source expression that contains the JSON data
- */
- private Expression sourceExpression;
-
- /**
- * The JSON path to access (e.g., "user.name", "items[0].price")
- */
- private String jsonPath;
-
- public JsonPathExpression(SourceLocation location, Expression sourceExpression, String jsonPath) {
- super(location);
- this.sourceExpression = sourceExpression;
- this.jsonPath = jsonPath;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitJsonPathExpression(this);
- }
-
- @Override
- public ExpressionType getExpressionType() {
- // JSON path can return any type depending on the path
- return ExpressionType.ANY;
- }
-
- @Override
- public boolean isConstant() {
- // JSON path expressions are not constant since they depend on runtime data
- return false;
- }
-
- @Override
- public boolean hasVariableReferences() {
- return sourceExpression != null && sourceExpression.hasVariableReferences();
- }
-
- @Override
- public String toDebugString() {
- return String.format("JsonPath(%s.%s)",
- sourceExpression != null ? sourceExpression.toDebugString() : "null",
- jsonPath);
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/RestCallExpression.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/RestCallExpression.java
deleted file mode 100644
index 0873ac1..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/RestCallExpression.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * Represents a REST API call expression.
- * Examples:
- * - rest_get("https://api.example.com/users/123")
- * - rest_post("https://api.example.com/users", {"name": "John", "email": "john@example.com"})
- * - rest_put("https://api.example.com/users/123", userData, {"Authorization": "Bearer token"})
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class RestCallExpression extends Expression {
-
- /**
- * HTTP method (GET, POST, PUT, DELETE, etc.)
- */
- private String httpMethod;
-
- /**
- * URL expression for the REST endpoint
- */
- private Expression urlExpression;
-
- /**
- * Optional request body expression (for POST, PUT, etc.)
- */
- private Expression bodyExpression;
-
- /**
- * Optional headers expression (Map)
- */
- private Expression headersExpression;
-
- /**
- * Optional timeout in milliseconds
- */
- private Expression timeoutExpression;
-
- public RestCallExpression(SourceLocation location, String httpMethod, Expression urlExpression) {
- super(location);
- this.httpMethod = httpMethod.toUpperCase();
- this.urlExpression = urlExpression;
- }
-
- public RestCallExpression(SourceLocation location, String httpMethod, Expression urlExpression,
- Expression bodyExpression, Expression headersExpression, Expression timeoutExpression) {
- super(location);
- this.httpMethod = httpMethod.toUpperCase();
- this.urlExpression = urlExpression;
- this.bodyExpression = bodyExpression;
- this.headersExpression = headersExpression;
- this.timeoutExpression = timeoutExpression;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitRestCallExpression(this);
- }
-
- @Override
- public ExpressionType getExpressionType() {
- // REST calls return JSON objects/arrays
- return ExpressionType.ANY;
- }
-
- @Override
- public boolean isConstant() {
- // REST calls are never constant since they involve external API calls
- return false;
- }
-
- @Override
- public boolean hasVariableReferences() {
- return (urlExpression != null && urlExpression.hasVariableReferences()) ||
- (bodyExpression != null && bodyExpression.hasVariableReferences()) ||
- (headersExpression != null && headersExpression.hasVariableReferences()) ||
- (timeoutExpression != null && timeoutExpression.hasVariableReferences());
- }
-
- @Override
- public String toDebugString() {
- StringBuilder sb = new StringBuilder();
- sb.append("RestCall(").append(httpMethod).append(" ");
- sb.append(urlExpression != null ? urlExpression.toDebugString() : "null");
-
- if (bodyExpression != null) {
- sb.append(", body=").append(bodyExpression.toDebugString());
- }
- if (headersExpression != null) {
- sb.append(", headers=").append(headersExpression.toDebugString());
- }
- if (timeoutExpression != null) {
- sb.append(", timeout=").append(timeoutExpression.toDebugString());
- }
-
- sb.append(")");
- return sb.toString();
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/CustomFunctionRegistry.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/CustomFunctionRegistry.java
new file mode 100644
index 0000000..337fd1d
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/CustomFunctionRegistry.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.fireflyframework.rules.core.dsl.function;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Registry of user-defined {@link RuleFunction} implementations available to the rule DSL.
+ *
+ * The registry is consulted before the built-in function catalog, allowing applications to
+ * extend or override the engine without modifying its source.
+ *
+ *
Lookups are case-insensitive. The registry is thread-safe.
+ */
+@Component
+public class CustomFunctionRegistry {
+
+ private final Map functions = new ConcurrentHashMap<>();
+
+ /**
+ * Register a function under the given name. If a function with the same name (ignoring case)
+ * is already registered, it is replaced.
+ *
+ * @param name the function name as referenced from the DSL; must not be {@code null} or blank
+ * @param function the function implementation; must not be {@code null}
+ * @throws IllegalArgumentException if {@code name} is blank or {@code function} is {@code null}
+ */
+ public void register(String name, RuleFunction function) {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Function name must not be blank");
+ }
+ if (function == null) {
+ throw new IllegalArgumentException("Function must not be null");
+ }
+ functions.put(name.toLowerCase(Locale.ROOT), function);
+ }
+
+ /**
+ * Remove a registered function by name. No-op if the name is not registered.
+ *
+ * @return {@code true} if the function existed and was removed
+ */
+ public boolean unregister(String name) {
+ if (name == null) return false;
+ return functions.remove(name.toLowerCase(Locale.ROOT)) != null;
+ }
+
+ /**
+ * Look up a registered function by name (case-insensitive).
+ */
+ public Optional lookup(String name) {
+ if (name == null) return Optional.empty();
+ return Optional.ofNullable(functions.get(name.toLowerCase(Locale.ROOT)));
+ }
+
+ /**
+ * Return the names of all currently registered functions (case folded to lower-case).
+ * The returned set is an immutable snapshot.
+ */
+ public Set registeredNames() {
+ return Collections.unmodifiableSet(Set.copyOf(functions.keySet()));
+ }
+
+ /**
+ * Whether a function with the given name (case-insensitive) is registered.
+ */
+ public boolean contains(String name) {
+ return lookup(name).isPresent();
+ }
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleFunction.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleFunction.java
new file mode 100644
index 0000000..6a0f99d
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleFunction.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.fireflyframework.rules.core.dsl.function;
+
+/**
+ * Pluggable function callable from the rule DSL via the {@code call} action or as part of an
+ * expression (e.g., {@code calculate result as my_function(amount, rate)}).
+ *
+ * Implementations are registered with {@link CustomFunctionRegistry}. Custom functions are
+ * looked up before the built-in catalog, so a registration with the same name as a
+ * built-in (e.g., {@code "max"}) deliberately shadows the built-in. Names are matched
+ * case-insensitively.
+ *
+ *
Contract
+ *
+ *
Arguments arrive as evaluated values (literals, resolved variables, or nested
+ * function results) -- not as AST nodes.
+ *
Implementations should throw {@link IllegalArgumentException} for invalid argument
+ * counts or types; the evaluator surfaces these to the caller.
+ *
Implementations must be thread-safe; the same instance can be invoked concurrently
+ * across rule evaluations.
+ *
+ */
+@FunctionalInterface
+public interface RuleFunction {
+
+ /**
+ * Apply this function to the evaluated argument list.
+ *
+ * @param args evaluated argument values (may be empty, never {@code null})
+ * @return the function result; may be {@code null} if the function legitimately returns a null value
+ */
+ Object apply(Object[] args);
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleInvoker.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleInvoker.java
new file mode 100644
index 0000000..20fc5ce
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleInvoker.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package org.fireflyframework.rules.core.dsl.function;
+
+import java.util.Map;
+
+/**
+ * Cross-rule composition hook. Implementations resolve a stored rule by its
+ * {@code code} and evaluate it against the supplied inputs, returning the
+ * resulting output map. Used by the {@code invoke_rule(code, inputs)} built-in
+ * so rules can call other rules without coupling the evaluator directly to the
+ * services layer.
+ *
+ *
Implementations must be safe to call from a {@code Schedulers.boundedElastic()}
+ * worker (the synchronous visitor's host thread). They are expected to block
+ * until the invoked rule completes -- nested invocations cost a thread but keep
+ * the visitor model simple and avoid threading a Mono through the AST.
+ */
+public interface RuleInvoker {
+
+ /**
+ * Synchronously evaluate the rule with the given code against the given inputs.
+ *
+ * @param code the stored rule's code (matches RuleDefinition.code)
+ * @param inputData the inputs passed to the nested rule
+ * @return the output map produced by the nested rule (never {@code null};
+ * empty if the nested rule succeeded but produced no outputs)
+ * @throws RuntimeException if the rule does not exist, fails to parse, or the nested
+ * evaluation reports {@code success=false}
+ */
+ Map invokeBlocking(String code, Map inputData);
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java
index f733b72..d898074 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java
@@ -46,24 +46,30 @@ public class ASTRulesDSL {
// Input/Output definitions
private Map input;
private Map output;
-
+
+ // Per-input default values (applied when caller omits the variable)
+ private Map inputDefaults;
+
// Constants
private List constants;
-
+
// Simple syntax support (when/then/else)
private List whenConditions;
private List thenActions;
private List elseActions;
-
+
// Complex syntax support (multiple rules)
private List rules;
-
+
// Complex conditions block
private ASTConditionalBlock conditions;
- // Circuit breaker configuration
- private ASTCircuitBreakerConfig circuitBreaker;
-
+ // DMN-style decision table block (mutually exclusive with the syntaxes above)
+ private ASTDecisionTable decisionTable;
+
+ // Per-rule wall-clock timeout in milliseconds; 0 / null means no timeout.
+ private Long timeoutMs;
+
/**
* AST-based sub-rule definition
*/
@@ -74,15 +80,65 @@ public class ASTRulesDSL {
public static class ASTSubRule {
private String name;
private String description;
-
+
+ // Drools-style salience: higher priority sub-rules evaluate first.
+ // Default (when unspecified) is 0; sub-rules with equal priority preserve YAML order.
+ @Builder.Default
+ private int priority = 0;
+
// Simple syntax
private List whenConditions;
private List thenActions;
private List elseActions;
-
+
// Complex syntax
private ASTConditionalBlock conditions;
}
+
+ /**
+ * DMN-style decision table. Each row is a list of input conditions plus a list
+ * of output assignments. The hit policy controls which matching rows contribute
+ * to the output.
+ */
+ @Data
+ @Builder
+ @AllArgsConstructor
+ @NoArgsConstructor
+ public static class ASTDecisionTable {
+ private List inputs;
+ private List outputs;
+ @Builder.Default
+ private HitPolicy hitPolicy = HitPolicy.FIRST;
+ private List rows;
+ }
+
+ @Data
+ @Builder
+ @AllArgsConstructor
+ @NoArgsConstructor
+ public static class ASTDecisionRow {
+ // Optional row label (for diagnostics)
+ private String name;
+ // Input conditions; each entry mirrors a normal `when:` predicate (string form).
+ // The evaluator parses them into ComparisonCondition at evaluation time.
+ private List when;
+ // Output assignments: column name -> value expression
+ private Map outputs;
+ // Optional fallback row -- if true, this row matches when no others did (DMN "else").
+ @Builder.Default
+ private boolean otherwise = false;
+ }
+
+ public enum HitPolicy {
+ /** First matching row wins. */
+ FIRST,
+ /** Collect outputs from every matching row into a list per output column. */
+ COLLECT,
+ /** Any matching row's outputs apply; rows must produce identical outputs. */
+ ANY,
+ /** Exactly one row must match; otherwise the rule fails loudly. */
+ UNIQUE
+ }
/**
* AST-based conditional block
@@ -143,6 +199,13 @@ public boolean isMultipleRulesSyntax() {
public boolean isComplexConditionsSyntax() {
return conditions != null;
}
+
+ /**
+ * Check if this rule is a decision table.
+ */
+ public boolean isDecisionTable() {
+ return decisionTable != null;
+ }
/**
* Get all conditions from this rule (regardless of syntax)
@@ -208,17 +271,4 @@ public List getAllActions() {
return List.of();
}
- /**
- * AST-based circuit breaker configuration
- */
- @Data
- @Builder
- @AllArgsConstructor
- @NoArgsConstructor
- public static class ASTCircuitBreakerConfig {
- private boolean enabled;
- private int failureThreshold;
- private String timeoutDuration;
- private String recoveryTimeout;
- }
}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java
index 5105dcd..0f68948 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java
@@ -33,8 +33,6 @@
import java.util.List;
import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -46,6 +44,7 @@
@Slf4j
public class ASTRulesDSLParser {
+ @lombok.Getter
private final DSLParser dslParser;
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
private final ComplexConditionsParser complexConditionsParser;
@@ -110,12 +109,11 @@ public Mono parseRulesReactive(String rulesDefinition) {
}
/**
- * Parse rules definition from YAML string to AST model (synchronous)
- * Uses caching to avoid re-parsing identical YAML content.
- *
- * @deprecated Use parseRulesReactive() instead for reactive contexts
+ * Parse rules definition from YAML to AST -- synchronous convenience wrapper around
+ * {@link #parseRulesReactive(String)}. Intended for non-reactive callers (tests,
+ * code generation, validation tools); reactive callers should subscribe to the Mono
+ * directly. Internally blocks; safe to call from non-event-loop threads.
*/
- @Deprecated
public ASTRulesDSL parseRules(String rulesDefinition) {
return parseRulesReactive(rulesDefinition).block();
}
@@ -124,7 +122,11 @@ public ASTRulesDSL parseRules(String rulesDefinition) {
* Internal method to parse rules definition without caching logic
*/
private ASTRulesDSL parseRulesInternal(String rulesDefinition) throws Exception {
- // Parse YAML to Map first
+ // Pre-flight lint: catch the most common authoring trap (unquoted colon in action
+ // strings) and surface a clean error pointing to the offending line, instead of
+ // letting SnakeYAML throw a confusing parse error about an unexpected map.
+ lintYaml(rulesDefinition);
+
@SuppressWarnings("unchecked")
Map yamlMap = yamlMapper.readValue(rulesDefinition, Map.class);
@@ -132,6 +134,63 @@ private ASTRulesDSL parseRulesInternal(String rulesDefinition) throws Exception
return convertToASTModel(yamlMap);
}
+ /**
+ * Pre-parse lint pass. Scans for action-list entries containing an unquoted ':'
+ * (a string with a colon must be YAML-quoted, otherwise SnakeYAML interprets it as
+ * a sub-map). Throws ASTException with a precise line number when found.
+ */
+ private static final java.util.regex.Pattern ACTION_VERB_LINE = java.util.regex.Pattern.compile(
+ "^\\s*-\\s+(set|calculate|run|call|add|subtract|multiply|divide|append|prepend|remove|log|print|invoke_rule|stop)\\b.*");
+
+ private void lintYaml(String yaml) {
+ String[] lines = yaml.split("\\r?\\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ String raw = lines[i];
+ String body = raw;
+ // ignore comments
+ int hash = body.indexOf('#');
+ if (hash >= 0) body = body.substring(0, hash);
+ if (!ACTION_VERB_LINE.matcher(body).matches()) continue;
+ // Strip enclosing single or double quotes on the value (lazy detection: look for
+ // matching quote starting after the verb)
+ String trimmed = body.trim();
+ // Strip "- " prefix
+ String afterDash = trimmed.startsWith("- ") ? trimmed.substring(2).trim() : trimmed;
+ if (afterDash.startsWith("'") || afterDash.startsWith("\"")) continue;
+ // Look for an unquoted ': ' (colon followed by space) which YAML will interpret as map syntax
+ if (containsUnquotedColonSpace(afterDash)) {
+ throw new ASTException(
+ "YAML lint: line " + (i + 1) + " contains an unquoted ': ' inside an action -- "
+ + "wrap the action in single quotes. Offending line: '" + raw.trim() + "'");
+ }
+ }
+ }
+
+ private boolean containsUnquotedColonSpace(String s) {
+ boolean inSingle = false, inDouble = false;
+ int parenDepth = 0, braceDepth = 0, bracketDepth = 0;
+ for (int i = 0; i < s.length() - 1; i++) {
+ char c = s.charAt(i);
+ if (c == '\'' && !inDouble) inSingle = !inSingle;
+ else if (c == '"' && !inSingle) inDouble = !inDouble;
+ else if (!inSingle && !inDouble) {
+ switch (c) {
+ case '(' -> parenDepth++;
+ case ')' -> parenDepth = Math.max(0, parenDepth - 1);
+ case '{' -> braceDepth++;
+ case '}' -> braceDepth = Math.max(0, braceDepth - 1);
+ case '[' -> bracketDepth++;
+ case ']' -> bracketDepth = Math.max(0, bracketDepth - 1);
+ case ':' -> {
+ if (parenDepth == 0 && braceDepth == 0 && bracketDepth == 0
+ && s.charAt(i + 1) == ' ') return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
/**
* Invalidate cached AST for specific YAML content.
* Useful when rule definitions are updated.
@@ -174,26 +233,33 @@ private ASTRulesDSL convertToASTModel(Map yamlMap) {
builder.metadata(metadata);
}
- // Input/Output definitions
- // Handle both 'input' and 'inputs' (both can be map format)
- if (yamlMap.containsKey("input")) {
- builder.input((Map) yamlMap.get("input"));
- } else if (yamlMap.containsKey("inputs")) {
- Object inputsObj = yamlMap.get("inputs");
- if (inputsObj instanceof Map) {
- // inputs is a map format: inputs: {income: "number", debt: "number"}
- builder.input((Map) inputsObj);
- } else {
- // inputs is a list format: inputs: [income, debt, age]
- List inputsList = convertToStringList(inputsObj);
- Map inputsMap = inputsList.stream()
- .collect(Collectors.toMap(
- inputName -> inputName,
- inputName -> "Object" // Default type since inputs list doesn't specify types
- ));
- builder.input(inputsMap);
+ // Input definitions. Accepts three shapes:
+ // inputs: [creditScore, age] -- list of names (type = Object)
+ // inputs: {creditScore: "number", age: "number"} -- name -> type
+ // inputs: {creditScore: {type: "number", default: 0}} -- name -> {type, default}
+ // The richer form lets callers omit the variable; the declared default is then injected.
+ Map resolvedInputs = new java.util.LinkedHashMap<>();
+ Map resolvedDefaults = new java.util.LinkedHashMap<>();
+ Object inputsObj = yamlMap.containsKey("input") ? yamlMap.get("input") : yamlMap.get("inputs");
+ if (inputsObj instanceof Map, ?> rawMap) {
+ for (Map.Entry, ?> e : rawMap.entrySet()) {
+ String name = String.valueOf(e.getKey());
+ Object value = e.getValue();
+ if (value instanceof Map, ?> spec) {
+ Object type = spec.get("type");
+ resolvedInputs.put(name, type == null ? "Object" : type.toString());
+ if (spec.containsKey("default")) {
+ resolvedDefaults.put(name, spec.get("default"));
+ }
+ } else {
+ resolvedInputs.put(name, value == null ? "Object" : value.toString());
+ }
}
+ } else if (inputsObj instanceof List> rawList) {
+ for (Object item : rawList) resolvedInputs.put(String.valueOf(item), "Object");
}
+ if (!resolvedInputs.isEmpty()) builder.input(resolvedInputs);
+ if (!resolvedDefaults.isEmpty()) builder.inputDefaults(resolvedDefaults);
// Handle both 'output' and 'outputs' (both can be map format)
if (yamlMap.containsKey("output")) {
@@ -243,15 +309,18 @@ private ASTRulesDSL convertToASTModel(Map yamlMap) {
builder.elseActions(elseActions);
}
- // Multiple rules syntax
+ // Multiple rules syntax. Sub-rules are sorted by descending priority so a
+ // higher-`priority:` sub-rule evaluates first (DRL-style salience). Ties preserve
+ // YAML declaration order via a stable sort.
if (yamlMap.containsKey("rules")) {
List