Otter Streams logo Otter Streams v0.0.4
Flink 1.15–1.20 GitHub

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

YAML
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"
Java
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.

Inference → Rule Engine → Decision → downstream
flowchart LR subgraph CFG["Rule Configuration (pick one)"] Y["rules.yaml
(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
Conditions are compiled once at rule-set load time — evaluation never re-parses a condition string.

Configuration Formats

Three equally-supported ways to define a rule set — pick whichever fits your team's config tooling:

FormatClassBest for
YAML (default)YamlRuleSetSourceNested rule lists read naturally; the recommended default
PropertiesPropertiesRuleSetSourceTeams whose config tooling standardizes on flat .properties files
Java classimplement RuleSetSourceFully programmatic rules, computed or generated at startup

Evaluation Modes

ModeBehavior
SINGLEStops at the first (highest-priority) matching rule. matchedRuleIds has at most one entry.
MULTIPLEEvaluates every enabled rule; matchedRuleIds can have several entries (e.g. flagged as both HIGH_RISK and NEW_ACCOUNT).
BatchCall 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.

Decision engine connector options
flowchart TB IR["InferenceResult"] --> CHOICE{"Where should
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
Java
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.

Java
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:

Java
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.