LEARN · RULES, HEURISTICS & SYMBOLIC AI
Some decisions should be learned. Others should be declared.
Machine learning is excellent when the question is predictive:
-
How likely is this order to be fraudulent?
-
Which products might this customer buy?
-
What will demand look like next week?
-
How long will this delivery probably take?
But many production systems ask a fundamentally different kind of question:
-
Is this customer eligible for this promotion?
-
Which discount does the current pricing policy allow?
-
Must this transaction be sent to manual review?
-
Can an account in this jurisdiction use this feature?
-
Which approval level is required for an order of this size?
Those are usually policy questions, not prediction problems.
If the business already knows the rule, asking a model to rediscover it from historical data is often a category error. A model approximates patterns. A policy says what must happen.
That distinction becomes especially important when a decision must be reproducible months later. If an auditor asks, “Why was customer A rejected on March 17?” the ideal answer is not “because model version 37 emitted 0.613.” It is something closer to:
Policy version 2026.03 rejected the request because the account had existed for 12 days, while rule ELIG-002 required at least 30 days.
That is the territory of a business rules engine.
What a rules engine actually does
At its simplest, a rules engine evaluates explicit conditions against input data and produces deterministic consequences.
Imagine an e-commerce company with this checkout promotion policy:
-
Customers must be in Germany, France, or the Netherlands.
-
Their account must be at least 30 days old.
-
Their order must be worth at least €50.
-
If all conditions pass, the promotion is available.
You could write that directly in application code:
def eligible(customer, order):
if customer["country"] not in {"DE", "FR", "NL"}:
return False
if customer["account_age_days"] < 30:
return False
if order["total"] < 50:
return False
return True
There is nothing inherently wrong with that function.
The trouble begins when the real policy becomes 60 conditions maintained by pricing, legal, operations, and finance; when conditions vary by market; when changes happen weekly; when every decision needs a reason code; and when compliance needs to reconstruct the exact policy in effect six months ago.
At that point, ordinary conditional code is still technically capable of expressing the policy, but its governance model is weak.
A rules engine turns the policy itself into a first-class artifact.
That artifact can be:
-
reviewed separately from application infrastructure;
-
represented as a decision table;
-
assigned an explicit version;
-
tested against boundary cases;
-
promoted between environments;
-
traced during execution;
-
associated with reason codes and policy documentation.
The important architectural change is therefore not “replace if with a fancy library.” It is separate volatile decision policy from stable application mechanics.
Rules and models answer different questions
A useful mental model is to separate descriptive uncertainty from normative certainty.
Machine learning handles descriptive uncertainty:
Based on previous observations, what is likely to happen?
Rules encode normative certainty:
According to the policy currently in force, what are we supposed to do?
Consider fraud prevention.
A machine-learning model might produce:
{
"fraud_probability": 0.87
}
That prediction does not itself tell the organization what action is permitted.
The operational policy might say:
-
below 0.20: approve automatically;
-
0.20 through 0.74: perform additional checks;
-
0.75 or higher: send to manual review;
-
regardless of score, transactions above €20,000 require manual review;
-
regardless of score, certain account states must be blocked.
The model estimates risk. The rule layer converts risk into an organizational action.
This separation is valuable because you can retrain the model without silently rewriting policy, and you can change policy thresholds without retraining the model.
It also gives you a clean audit trail:
model_version: fraud-2026-07-18 fraud_probability: 0.87 policy_version: checkout-review-4.3.1 matched_rule: HIGH_RISK_MANUAL_REVIEW decision: manual_review
That is much easier to reason about than embedding both statistical inference and organizational policy in one opaque pipeline.
The current engine landscape: Apache KIE/Drools and GoRules
Two useful points on the rules-engine spectrum are Apache KIE’s Drools engine and GoRules.
Apache KIE 10.2.0 was released in April 2026 and includes rule-engine fixes, executable-model improvements, Java ecosystem updates, and DMN 1.6 support. The project is currently an Apache Incubator project. Its 10-series documentation requires at least Java 17, and the current guidance recommends drools-engine rather than the deprecated classic engine/MVEL dependency combination.
Drools is particularly compelling when your domain needs more than simple spreadsheet-style lookup tables. Its engine works with facts, working memory, an agenda of rule activations, inference, logical insertions, agenda groups, stateful sessions, and complex event processing. That makes it suitable for domains in which many rules interact and one rule can introduce information that enables another.
GoRules comes from a different direction. Its ZEN Engine is an embeddable, open-source engine written in Rust with SDKs for multiple languages, including Python. Decisions are represented using JSON Decision Model files, or JDM: graphs containing nodes, edges, expressions, and decision tables. Its current Python integration installs as zen-engine and evaluates decisions directly in-process.
The practical distinction is less “old versus new” and more about the shape of the problem.
Use a Drools-style production rule system when you need things such as:
-
many interacting facts;
-
forward chaining;
-
inference;
-
stateful sessions;
-
complex event processing;
-
sophisticated activation behavior;
-
a mature Java-centric ecosystem.
A decision-graph system such as GoRules is attractive when the central problem looks more like:
-
eligibility;
-
pricing matrices;
-
commissions;
-
approval thresholds;
-
routing;
-
deterministic risk bands;
-
decision tables owned jointly by technical and business teams.
For the rest of this tutorial, we will use GoRules because it gives us a compact Python example while still treating the rule definition as an external, versionable artifact.
Build a runnable eligibility decision with ZEN Engine
Create a small project:
mkdir eligibility-rules cd eligibility-rules python -m venv .venv
Activate the environment on Linux or macOS:
source .venv/bin/activate
On Windows PowerShell:
.venv\Scripts\Activate.ps1
Install the current Python package documented by GoRules:
pip install zen-engine
The Python SDK loads JDM content with zen.ZenEngine(), creates a decision using create_decision(), and evaluates Python dictionaries using decision.evaluate(...).
Create eligibility.json:
{
"nodes": [
{
"id": "input",
"type": "inputNode",
"name": "Request",
"position": {
"x": 0,
"y": 100
},
"content": {
"schema": ""
}
},
{
"id": "eligibility",
"type": "decisionTableNode",
"name": "Promotion Eligibility",
"position": {
"x": 250,
"y": 100
},
"content": {
"hitPolicy": "first",
"inputs": [
{
"id": "country",
"name": "Country",
"field": "customer.country"
},
{
"id": "account_age",
"name": "Account Age Days",
"field": "customer.accountAgeDays"
},
{
"id": "order_total",
"name": "Order Total",
"field": "order.total"
}
],
"outputs": [
{
"id": "eligible",
"name": "Eligible",
"field": "eligible"
},
{
"id": "reason",
"name": "Reason",
"field": "reason"
}
],
"rules": [
{
"_id": "account-too-new",
"country": "'DE', 'FR', 'NL'",
"account_age": "< 30",
"order_total": "",
"eligible": "false",
"reason": "\"account_too_new\""
},
{
"_id": "minimum-order",
"country": "'DE', 'FR', 'NL'",
"account_age": "",
"order_total": "< 50",
"eligible": "false",
"reason": "\"minimum_order_not_met\""
},
{
"_id": "eligible",
"country": "'DE', 'FR', 'NL'",
"account_age": "",
"order_total": "",
"eligible": "true",
"reason": "\"eligible\""
},
{
"_id": "unsupported-country",
"country": "",
"account_age": "",
"order_total": "",
"eligible": "false",
"reason": "\"unsupported_country\""
}
],
"passThrough": false,
"inputField": null,
"outputPath": null,
"executionMode": "single"
}
},
{
"id": "output",
"type": "outputNode",
"name": "Response",
"position": {
"x": 500,
"y": 100
},
"content": {
"schema": ""
}
}
],
"edges": [
{
"id": "input-to-eligibility",
"sourceId": "input",
"targetId": "eligibility",
"sourceHandle": null,
"type": "edge"
},
{
"id": "eligibility-to-output",
"sourceId": "eligibility",
"targetId": "output",
"sourceHandle": null,
"type": "edge"
}
],
"metadata": {
"version": "1.0.0",
"description": "Checkout promotion eligibility policy"
}
}
This structure follows the current documented JDM model: input, output, and decision-table nodes are connected with edges, while table rows are keyed by the IDs of their input and output columns. GoRules’ current unary-test syntax supports comparisons such as < 30, lists such as 'DE', 'FR', 'NL', ranges, and combined conditions. Empty decision-table cells act as wildcards.
Notice the row ordering.
We use a first-hit policy, so precedence is explicit:
-
reject young accounts;
-
reject undersized orders;
-
accept otherwise-qualified supported countries;
-
reject everything else as an unsupported country.
The order is business meaning, not implementation trivia. If someone moves the catch-all row to the top, every request will be rejected.
That is why rule tables deserve the same review discipline as source code.
Now create app.py:
import zen with open("eligibility.json", encoding="utf-8") as rules_file: rules = rules_file.read() engine = zen.ZenEngine() decision = engine.create_decision(rules) cases = [ ( "eligible customer", { "customer": { "country": "DE", "accountAgeDays": 120 }, "order": { "total": 75 } } ), ( "new account", { "customer": { "country": "FR", "accountAgeDays": 12 }, "order": { "total": 75 } } ), ( "small order", { "customer": { "country": "NL", "accountAgeDays": 120 }, "order": { "total": 35 } } ), ( "unsupported country", { "customer": { "country": "US", "accountAgeDays": 120 }, "order": { "total": 75 } } ) ] for label, payload in cases: response = decision.evaluate(payload) print(label) print(response["result"])
Run it:
python app.py
The logical results are:
eligible customer
{'eligible': True, 'reason': 'eligible'}
new account
{'eligible': False, 'reason': 'account_too_new'}
small order
{'eligible': False, 'reason': 'minimum_order_not_met'}
unsupported country
{'eligible': False, 'reason': 'unsupported_country'}
The exact string formatting of Python dictionaries is not important. What matters is that the policy returns both an outcome and a reason code.
That second field is one of the most useful habits you can build into a rule system.
Never return only true or false
A production decision API should rarely return this:
{
"eligible": false
}
Prefer something like:
{
"eligible": false,
"reason": "account_too_new",
"policyVersion": "1.0.0"
}
You might eventually add:
{
"eligible": false,
"reason": "account_too_new",
"policyVersion": "1.0.0",
"ruleId": "account-too-new",
"decisionId": "01JXYZ...",
"evaluatedAt": "2026-08-10T14:30:00Z"
}
That turns the decision from an ephemeral boolean into evidence.
You can now answer operational questions:
-
Which rule denied the request?
-
Which policy version contained that rule?
-
How many denials came from that rule this week?
-
Did a policy change increase rejection rates?
-
Can we reproduce yesterday’s decision?
-
Did the application send different input than we expected?
A rules engine does not magically provide good governance. It gives you a useful place to implement it.
Trace the decision, not just the final output
GoRules’ current Python SDK can enable tracing during evaluation. The trace is designed to expose node execution, inputs, outputs, and performance information.
For example:
import zen with open("eligibility.json", encoding="utf-8") as rules_file: rules = rules_file.read() engine = zen.ZenEngine() decision = engine.create_decision(rules) payload = { "customer": { "country": "FR", "accountAgeDays": 12 }, "order": { "total": 75 } } response = decision.evaluate( payload, { "trace": True } ) print(response["result"]) print(response["trace"]) print(response["performance"])
Tracing should normally be treated separately from your public API response. Internal traces may contain data that you do not want to expose to end users.
A good architecture stores enough evaluation metadata for auditability while returning a deliberately designed explanation to the caller.
Those are related concerns, but they are not identical.
Version the policy independently
Suppose marketing changes the minimum order from €50 to €70.
The most important engineering question is not whether replacing one number is easy.
It obviously is.
The important questions are:
-
Who requested the change?
-
Who approved it?
-
When does it become effective?
-
Which markets does it affect?
-
What tests demonstrate the intended behavior?
-
Can we reproduce decisions made under the previous threshold?
-
Can we roll back without rebuilding the entire application?
JDM is ordinary JSON, which makes it suitable for storing and diffing in Git. GoRules’ current documentation explicitly describes JDM as portable and version-controllable and recommends keeping decision files in source control with meaningful commits.
A repository might look like this:
checkout-service/ ├── app/ │ ├── api.py │ └── checkout.py ├── rules/ │ ├── eligibility.json │ └── pricing.json ├── tests/ │ ├── test_eligibility.py │ └── test_pricing.py └── requirements.txt
A policy modification can then have a focused commit:
git add rules/eligibility.json tests/test_eligibility.py git commit -m "Raise promotion minimum order from EUR 50 to EUR 70"
That diff is dramatically easier for a non-ML reviewer to understand than a changed gradient-boosted model artifact.
Test boundaries aggressively
Rules fail most often around boundaries, precedence, missing values, and unexpected combinations.
If the threshold is 30 days, test at least:
-
29 days;
-
exactly 30 days;
-
31 days.
For the €50 threshold:
-
€49.99;
-
€50.00;
-
€50.01.
For a first-hit table, test every row plus combinations that could match multiple rows.
A simple Python test can keep the policy honest:
import zen with open("eligibility.json", encoding="utf-8") as rules_file: rules = rules_file.read() engine = zen.ZenEngine() decision = engine.create_decision(rules) def evaluate(country, account_age_days, order_total): response = decision.evaluate( { "customer": { "country": country, "accountAgeDays": account_age_days }, "order": { "total": order_total } } ) return response["result"] def test_account_age_boundary(): assert evaluate("DE", 29, 100)["reason"] == "account_too_new" assert evaluate("DE", 30, 100)["eligible"] is True assert evaluate("DE", 31, 100)["eligible"] is True def test_order_boundary(): assert evaluate("DE", 100, 49.99)["reason"] == "minimum_order_not_met" assert evaluate("DE", 100, 50)["eligible"] is True assert evaluate("DE", 100, 50.01)["eligible"] is True def test_country_policy(): assert evaluate("FR", 100, 100)["eligible"] is True assert evaluate("NL", 100, 100)["eligible"] is True assert evaluate("US", 100, 100)["reason"] == "unsupported_country"
The deeper principle is simple:
Business policy should have executable examples.
A document saying “accounts must be at least 30 days old” is useful.
A rule expressing it is better.
A rule plus automated tests showing what 29, 30, and 31 mean is much harder to misunderstand.
When Drools earns its extra machinery
The previous example is deliberately table-shaped. You do not need a heavyweight inference system for it.
Now imagine a logistics platform where facts continuously arrive:
-
shipment created;
-
temperature sensor exceeded threshold;
-
truck changed route;
-
warehouse closed;
-
delivery deadline approaching;
-
replacement vehicle available;
-
customer has priority status.
One fact may cause the engine to infer another fact, which activates additional rules. Some rules may be mutually exclusive. Others may belong to agenda groups or depend on temporal relationships.
That is the kind of environment where the Drools execution model becomes interesting.
Its engine matches facts against rule conditions, creates eligible activations, places them on an agenda, executes consequences, and can reevaluate rules as working memory changes. It also supports logical insertion and truth maintenance for inferred facts.
A simple eligibility table and a stateful inference engine are both called “rules engines,” but they solve different shapes of problem.
Choosing the smallest engine that matches your domain is usually better than choosing the engine with the longest feature list.
The most dangerous anti-pattern: training a model to imitate a written policy
Suppose your organization has historically approved refunds when all of these conditions are true:
-
purchase is less than 30 days old;
-
item value is below €200;
-
account is not under investigation;
-
item category is refundable.
You possess years of historical refund decisions.
Someone proposes training a classifier:
refund_request → predicted_approval
The model reaches 98.7% accuracy.
That sounds impressive until you ask why it should exist.
If the organization already has authoritative refund rules, the model is learning an imperfect approximation of rules you could execute exactly.
The remaining 1.3% error is not statistical noise you necessarily want.
It may represent customers receiving treatment that contradicts the actual policy.
Worse, the training data may encode:
-
previous employee mistakes;
-
obsolete policies;
-
inconsistent exceptions;
-
undocumented biases;
-
data-entry errors.
The model can faithfully learn all of them.
In that situation, “more data” does not solve the conceptual problem. You are asking prediction to replace specification.
Implement the policy directly.
Where machine learning and rules belong together
This does not mean deterministic rules should replace machine learning everywhere.
A mature decision architecture frequently uses both.
For example:
transaction | v feature pipeline | v fraud model | | fraud_probability = 0.81 v policy engine | +--> account blocked? ----------> reject | +--> amount > EUR 20,000? ------> manual review | +--> score >= 0.75? ------------> manual review | +--> otherwise -----------------> approve
Here each component has a clean responsibility.
The model answers:
What risk does the observed evidence suggest?
The policy engine answers:
Given that risk and our current organizational constraints, what action are we allowed or required to take?
That separation also makes experimentation safer.
You can run model version B in shadow mode and compare its scores without changing customer outcomes. Or you can modify a review threshold while keeping the underlying model frozen.
The architecture lets statistical change and policy change move at different speeds.
Auditability is not the same as interpretability
This distinction is easy to miss.
A machine-learning model may be interpretable enough to tell you that transaction amount, account age, and device characteristics contributed to a score.
That still does not mean you have an auditable policy decision.
Interpretability asks:
What factors influenced this model output?
Auditability asks:
Which authoritative policy version produced this organizational action, from which inputs, according to which rule, at what time?
A rules engine is naturally good at the second question because the decision procedure is explicit.
For high-consequence systems, you often need both.
Cherry on the cake: algorithms can become compliance problems, not just engineering problems
A useful recent warning came from France in 2025.
France’s independent rights watchdog, Défenseur des Droits, concluded in an October 10, 2025 decision that Facebook’s job-ad delivery system treated users differently based on sex and constituted indirect sex discrimination. The watchdog recommended corrective measures and gave Meta entities three months to report what they had done. Meta publicly disagreed with the decision and said it was assessing its options.
Why does that matter to a rules-engine discussion?
Because an opaque automated decision can create a governance problem even when nobody wrote an explicit discriminatory rule.
A learned system optimizes from data and objectives. Its operational policy may therefore emerge from complex model behavior rather than from a line a compliance officer can inspect.
When a requirement is genuinely normative — “do not make this decision on a prohibited basis,” “always send this category to human review,” “never exceed this statutory limit” — treating it merely as another pattern for the model to learn is dangerous.
Make hard constraints hard.
The regulatory environment is moving in the same direction. As of August 2026, major portions of the EU AI Act are applicable, with EU and member-state authorities taking on implementation and enforcement responsibilities, while the precise timetable for some high-risk categories has been adjusted by subsequent legislation.
The engineering lesson is not “rules automatically make you compliant.”
They do not.
The lesson is that explicit, versioned, testable decision logic gives governance teams something concrete to inspect, challenge, approve, and reproduce.
Rules engines have failure modes too
Determinism does not equal correctness.
A deterministic rule can be consistently wrong.
Common failure modes include:
-
contradictory rules;
-
unreachable rows;
-
overly broad catch-all conditions;
-
accidental precedence changes;
-
missing input validation;
-
stale policies that were never retired;
-
administrators editing production rules without review;
-
incomplete reason codes;
-
rule changes deployed without boundary tests.
There is also a security issue: changing a rule can be equivalent to changing application behavior.
Treat the rule repository accordingly.
Production rule changes should normally have:
-
authenticated authors;
-
role-based permissions;
-
review or approval;
-
immutable history;
-
automated tests;
-
signed or otherwise verifiable release artifacts where appropriate;
-
controlled promotion between environments;
-
monitoring after rollout.
If someone can casually change:
amount >= 10000 → manual_review
to:
amount >= 1000000 → manual_review
they have effectively changed a security or financial control.
Putting the number into a visual decision table does not reduce its importance.
Rules versus ordinary application code
Not every conditional deserves a rules engine.
This is application mechanics:
if request.method != "POST":
return method_not_allowed()
This is probably application mechanics too:
if connection_timeout_seconds <= 0:
raise ValueError("Timeout must be positive")
Externalizing those conditions into a policy platform would make the system harder to understand.
A candidate for externalized policy looks more like:
If customer segment = partner and annual spend >= EUR 100,000 and contract tier = platinum then discount ceiling = 18%
Ask whether the logic is:
-
owned by domain experts;
-
changed independently of core software;
-
likely to require audit history;
-
naturally expressed as a table or policy;
-
reused by multiple applications;
-
required to produce explicit reasons.
The more “yes” answers you have, the stronger the case for a dedicated decision layer.
Rules versus machine learning: a practical decision table
Use explicit rules when:
| Situation | Prefer |
|---|---|
| The policy is already known | Rules |
| The same input must reliably produce the same policy outcome | Rules |
| Exact thresholds have legal or contractual meaning | Rules |
| Auditors must reproduce old decisions | Rules |
| Business users need to inspect decision logic | Rules |
| Changes are policy changes rather than discoveries from data | Rules |
| You need explicit reason codes | Rules |
Use machine learning when:
| Situation | Prefer |
| The relationship must be inferred from examples | ML |
| Inputs contain complex statistical patterns | ML |
| Prediction quality matters more than a hand-written threshold | ML |
| The correct mapping cannot realistically be specified manually | ML |
| The target is probabilistic by nature | ML |
Use both when:
| Situation | Architecture |
| Predict risk, then apply an approval policy | Model → rules |
| Forecast demand, then enforce inventory constraints | Model → rules |
| Rank candidates, then apply explicit eligibility requirements | Model → rules |
| Detect anomalies, then determine escalation requirements | Model → rules |
| Estimate delivery time, then apply service-level obligations | Model → rules |
Do not make “rules versus AI” an ideological choice.
Make it a semantic one.
A production architecture that ages well
A clean service boundary might look like:
Client | v Application API | +--> validate request | +--> fetch authoritative business data | +--> optional predictive model | | | +--> score / prediction | +--> rules engine | | | +--> decision | +--> reason code | +--> matched rule | +--> policy version | +--> persist decision record | v Response
The application remains responsible for authentication, data retrieval, transactions, and side effects.
The model remains responsible for inference.
The rule system remains responsible for policy.
That separation gives each layer a clear contract.
Migrating from a forest of if statements
You do not have to rewrite a mature application in one migration.
Start with one policy that causes real pain.
Good candidates include:
-
discount eligibility;
-
shipping surcharges;
-
partner commissions;
-
approval thresholds;
-
refund eligibility;
-
manual-review routing.
Then follow a small sequence.
1. Write down the current behavior
Before changing architecture, capture what the application actually does.
Do not assume the wiki is accurate.
2. Convert examples into tests
Include:
-
normal cases;
-
boundary values;
-
historical edge cases;
-
previous incidents.
3. Extract reason codes
If the existing function returns only a boolean, make its rationale explicit.
4. Represent the policy as a decision table
Aim for language a domain owner can review.
5. Run both implementations
For a period, evaluate the old and new policy implementations side by side.
Log disagreements.
6. Investigate every disagreement
A disagreement might mean:
-
your new rule is wrong;
-
the old implementation contains a bug;
-
an undocumented exception exists;
-
the business description is ambiguous.
All four possibilities are valuable discoveries.
7. Switch authority only after equivalence is understood
Once the decision artifact is trusted, make it authoritative and delete duplicated policy from application code.
The deletion matters.
If policy exists in two places, you have created two sources of truth.
The deeper lesson
Modern software engineering spends enormous effort making uncertain systems smarter.
That is valuable.
But many business decisions are not uncertain.
A discount threshold does not need intelligence.
A contractual limit does not need prediction.
A jurisdictional restriction does not need embeddings.
An eligibility policy does not need to be inferred from last year’s applicants.
Sometimes the sophisticated architecture is the one that refuses to use machine learning where machine learning does not belong.
Rules engines make that choice operational.
They let you say:
-
this part of the system predicts;
-
this part recommends;
-
this part is policy;
-
and policy does not drift because the training distribution changed.
Your next step
Pick one decision in your current application that is buried inside conditional code.
Ask three questions:
-
Is the correct behavior already known?
-
Will someone eventually ask why a particular decision happened?
-
Would changing this logic be considered a business-policy change rather than a software-feature change?
If all three answers are yes, extract that decision into a versioned policy artifact.
Start with the GoRules eligibility example above if you want a lightweight Python path. If your domain instead needs interacting facts, inference, stateful reasoning, or complex event processing, prototype the same policy boundary with the current Apache KIE/Drools 10.x stack.
Then add the part teams most often postpone: boundary tests, explicit reason codes, policy-version logging, and controlled review.
The goal is not to eliminate intelligent models.
It is to make sure your system learns only what should be learned — and explicitly declares what the business already knows must be true.