Overview
Otter Streams' Rule Engine sits immediately after inference: it takes the
InferenceResult your model produced and applies a set of declarative rules
to it, producing a Decision — a primary flag, an optional category, which
rule(s) matched, and a hex color for dashboard rendering. This is conceptually the same
role Drools or Red Hat Decision Manager plays in traditional enterprise systems, purpose
-built for the streaming-inference case: fast (compiled conditions, no per-call
parsing), safe (a small boolean expression language, not a general scripting engine),
and configuration-first (YAML by default).
Quick Start
name: fraud-detection-rules
version: "3"
evaluationMode: SINGLE # highest-priority match wins
rules:
- id: high-risk-score
priority: 100
condition: "output.risk_score > 0.85"
flag: FRAUD
category: HIGH_RISK
color: "#c0392b"
description: "Blocks transactions with a model risk score above 0.85"
- id: medium-risk-score
priority: 50
condition: "output.risk_score > 0.5 && output.risk_score <= 0.85"
flag: REVIEW
color: "#b45309"
- id: default-approve
priority: 0
condition: "true"
flag: APPROVE
color: "#00875a"
RuleEngine engine = new DefaultRuleEngine(
YamlRuleSetSource.fromClasspath("rules.yaml"));
InferenceResult result = runtime.infer("fraud-detector", inputs);
Decision decision = engine.evaluate(result, Map.of());
if (decision.isFlagged()) {
System.out.println(decision.flag() + " via " + decision.matchedRuleIds());
}
Architecture
The Rule Engine composes with OtterRuntime rather than being wired into
it — it consumes the already-stable InferenceResult type, so
ml-inference-core has no dependency on the rule engine and vice versa.
(default)"] P["rules.properties"] C["RuleSetSource
(Java class)"] end CFG --> DRE["DefaultRuleEngine
compiles every condition once at load"] IR["InferenceResult
(from OtterRuntime.infer)"] --> DRE DRE -->|SINGLE or MULTIPLE mode| DEC["Decision
flag · category · matchedRuleIds · color"] DEC --> KFK["otter-stream-kafka
(publish downstream)"] DEC --> DASH["Rule Dashboard
(Otter Control Plane)"] DRE -.->|RuleMetricsSnapshot| DASH
Configuration Formats
Three equally-supported ways to define a rule set — pick whichever fits your team's config tooling:
| Format | Class | Best for |
|---|---|---|
| YAML (default) | YamlRuleSetSource | Nested rule lists read naturally; the recommended default |
| Properties | PropertiesRuleSetSource | Teams whose config tooling standardizes on flat .properties files |
| Java class | implement RuleSetSource | Fully programmatic rules, computed or generated at startup |
Evaluation Modes
| Mode | Behavior |
|---|---|
SINGLE | Stops at the first (highest-priority) matching rule. matchedRuleIds has at most one entry. |
MULTIPLE | Evaluates every enabled rule; matchedRuleIds can have several entries (e.g. flagged as both HIGH_RISK and NEW_ACCOUNT). |
| Batch | Call evaluateBatch(List<InferenceResult>, context) — sequential below 64 items, auto-parallelized above. |
External Connectors
Delegate to an external enterprise decision engine instead of evaluating in-process.
Rather than a bespoke SDK per vendor, RestDecisionEngineConnector is one
configurable connector that works against any REST-exposed decision engine — KIE Server
(Red Hat Decision Manager), Camunda DMN, IBM ODM, or an in-house service.
the decision
come from?"} CHOICE -->|in-process, fast| DRE["DefaultRuleEngine
(otter-stream-rules)
YAML / properties / class"] CHOICE -->|external, over REST| REST["RestDecisionEngineConnector
(otter-stream-rules)"] REST --> KIE["KIE Server /
Red Hat Decision Manager"] REST --> DMN["Camunda DMN
REST API"] REST --> ODM["IBM ODM
Decision Server"] REST --> INHOUSE["In-house
decision service"] CHOICE -->|embedded Drools, no REST| DRC["DroolsDecisionEngineConnector
(otter-stream-rules-drools,
isolated dependency)"] DRC --> KIESESSION["Embedded KIE Session"] DRE --> DEC["Decision"] REST --> DEC DRC --> DEC
DecisionEngineConnector connector = new RestDecisionEngineConnector(
"kie-server",
URI.create("https://decisions.mycompany.com/kie-server/decision"),
System.getenv("KIE_SERVER_TOKEN"));
Decision decision = connector.evaluate(result, Map.of());
For embedded Drools (not over REST), add the separate otter-stream-rules-drools
module — kept isolated specifically so its KIE dependency tree never lands on a project
that only wants the lightweight YAML engine.
Rule Dashboard
Every rule can carry a hex color, validated at load time, so dashboards
render consistently without arbitrary auto-assigned colors:
#c0392b — FRAUD / high risk ·
#b45309 — REVIEW / medium risk ·
#00875a — APPROVE
RuleEngine.getMetrics() returns a RuleMetricsSnapshot — per-rule
and per-flag hit counts, backed by LongAdder counters for low-contention
writes under concurrent evaluation. The Rule Dashboard (Otter Control Plane) polls or
receives this to render per-rule/per-flag charts using each rule's configured color —
see otter-control-plane/ARCHITECTURE.md for the broader observability
design this feeds into.
RuleMetricsSnapshot snapshot = engine.getMetrics(); snapshot.hitsByRuleId(); // Map<String, Long> snapshot.hitsByFlag(); // Map<String, Long> — FRAUD, REVIEW, APPROVE, ...
Publishing Decisions
Pair with otter-stream-kafka to publish both the raw inference result and
the rule-engine decision downstream:
KafkaSink<Decision> sink = OtterKafkaSinks.decisionSink(
"broker1:9092,broker2:9092", "fraud-decisions");
decisionStream.sinkTo(sink);
For any other stream system (Kinesis, Pulsar, a webhook), implement the generic
StreamResultSink<T> SPI in ml-inference-core.