Operators

Decision Table

Match typed inputs against rows of condition cells — the cell grammar, hit policies and aggregations, input coercion, build-time validation, and the YAML form.

DecisionTable turns a rules matrix into a node. Each input column names a typed value the node receives, each output column names a value it returns, and each rule is one row: a condition cell per input column and a literal cell per output column. The node coerces the inputs to their column types, evaluates the rules in table order, and returns the outputs of the matching rows together with matched_rules, the rows that fired. Rules compile once, when the node is built, so a malformed cell fails at construction rather than in the middle of a run.

A first table

The node runs standalone like any other, which is the quickest way to test a rule set:

from dynamiq.nodes.operators import DecisionTable
from dynamiq.nodes.types import DecisionRule, NamedField
from dynamiq.runnables import RunnableConfig

eligibility = DecisionTable(
    id="eligibility",
    name="eligibility",
    input_columns=[
        NamedField(name="fico", type="int"),
        NamedField(name="ltv", type="int"),
        NamedField(name="program", type="string"),
    ],
    output_columns=[NamedField(name="decision", type="string")],
    rules=[
        DecisionRule(id="e1", name="below floor", when=["< 580", "", ""], then=["decline"]),
        DecisionRule(id="e2", name="fha floor", when=["[580..619]", "", "FHA"], then=["review"]),
        DecisionRule(id="e3", name="conforming", when=["", "<= 97", ""], then=["approve"]),
        DecisionRule(id="e4", name="everything else", when=["", "", ""], then=["review"]),
    ],
)

result = eligibility.run(
    input_data={"fico": 700, "ltv": 85, "program": "FHA"},
    config=RunnableConfig(callbacks=[]),
)
print(result.output)
# {'decision': 'approve', 'matched_rules': [{'id': 'e3', 'name': 'conforming'}]}

Inputs arrive under the input column names. In a flow you map them like any other node input — with .inputs() or an input_transformer selector such as fico: $.start.output.fico — and downstream nodes read the outputs as $.eligibility.output.decision and $.eligibility.output.matched_rules. A selector addresses a node by its id, not its name, which is why the node above sets id="eligibility"; a node built without one gets a generated id, and a selector written against the name would then resolve to nothing without an error.

Configuration

namestr
Node name; defaults to "decision_table". Error messages and traces use it.
hit_policyDecisionHitPolicy | "first" | "unique" | "collect"
Which matching rules produce the output. Default: first.
aggregationDecisionAggregation | "list" | "sum" | "min" | "max" | "count"
How a collect table folds the outputs of every matching rule. Default: list. Ignored under first and unique.
input_columnslist[NamedField]
The named, typed values the rules check, in the order the when cells follow.
output_columnslist[NamedField]
The named, typed values a matching rule returns, in the order the then cells follow. The name matched_rules is reserved.
ruleslist[DecisionRule]
The rows, evaluated in list order.

NamedField and DecisionRule come from dynamiq.nodes.types:

ModelFields
NamedFieldid (generated when omitted), name, type — one of string, int, float, bool, Any (the default). Any other type fails at construction.
DecisionRuleid (generated when omitted), name (default ""), when (one cell per input column), then (one cell per output column), enabled (default True; a disabled rule is kept in the table but never matches).

Cells are strings as typed in the editor, but a YAML or JSON source may hold a number, a boolean, or null where the editor holds text; the node reads them the same way. A rule whose when or then length does not match the column count fails at construction.

The cell grammar

A when cell is a condition on its input, read as the column type. A rule matches when every cell holds.

CellMeaning
empty, *any value, including a missing one
700, FHA, trueequals the literal
"700"equals the text 700 — quotes keep a value as text
>= 620, < 0.8, != VA, == FHAcompares with the literal (= is accepted for ==)
[620..680], (0..1]within the numeric range; [ and ] include the bound, ( and ) exclude it
FHA, VA, USDAany of the alternatives
!= FHA, VAnone of the alternatives; == FHA, VA is any of them, and an ordering such as >= 620, 680 is refused

Literals are read as the column type, so a number column accepts only numbers, a bool column only true or false, and a string column keeps the text as written — 700 in a string column is the text "700". A number may be written in exponent form, 1e-5 or 2.5E+3, which is also how Python writes a small float back, so a YAML cell holding 0.00001 reads as that number. A range needs a numeric or Any column. Inside a list, a quote opens an alternative only at its start, so O'Brien, Smith is two names and "a, b", c is two alternatives.

A then cell is the value the rule returns, read as its column type; an empty cell returns None. Only numeric and boolean output columns can be malformed.

How inputs are read

Each input is coerced to its column type once per run, before the rules loop:

Column typeAcceptsAnything else
int, floatnumbers, and strings that look like numbers ("700" becomes 700, "1e6" becomes 1000000.0)unreadable
boolbooleans, and the strings true / false in any caseunreadable
stringstrings; numbers and booleans are converted to textunreadable (lists, dicts)
Anythe value as is—

An unreadable or missing value matches only an any cell — one left empty or holding *. It never satisfies a condition — not even != — so a missing input cannot slip through a negative rule, while a catch-all row of empty cells still catches it. Booleans never equal numbers: true does not match 1.

Hit policies

hit_policyBehavior
first (default)Evaluation stops at the first matching row in table order. Each output column holds that row's cell; no match yields None for every column.
uniqueEvery row is evaluated and at most one may match. Two or more matches fail the run with an error naming the overlapping rules — the way to catch a rule set that is meant to be exclusive.
collectEvery matching row contributes, and each output column is folded with aggregation (below).

Whatever the policy, matched_rules lists the rules that fired as {"id": ..., "name": ...} in table order, and a disabled rule never appears there.

Aggregations under collect

aggregationResult per output column
list (default)The matched cells in table order, None for an empty cell; [] when nothing matched.
countThe number of matching rules; 0 when nothing matched.
sum, min, maxThe fold of a numeric column (int, float, or an Any column whose then cells are all numbers, decided over the whole table so a run's shape never depends on which rows matched); None when nothing matched or every matched cell was empty. A column that is not numeric keeps the list.

Collect with sum is the scorecard pattern — every matching row adds its points:

from dynamiq.nodes.operators import DecisionTable
from dynamiq.nodes.types import DecisionRule, NamedField
from dynamiq.runnables import RunnableConfig

adjustments = DecisionTable(
    name="adjustments",
    hit_policy="collect",
    aggregation="sum",
    input_columns=[
        NamedField(name="fico", type="int"),
        NamedField(name="ltv", type="int"),
        NamedField(name="program", type="string"),
    ],
    output_columns=[NamedField(name="llpa", type="float")],
    rules=[
        DecisionRule(id="a1", name="mid fico", when=["[680..739]", "", ""], then=["0.75"]),
        DecisionRule(id="a2", name="low fico", when=["< 680", "", ""], then=["1.5"]),
        DecisionRule(id="a3", name="high ltv", when=["", "> 80", ""], then=["0.25"]),
        DecisionRule(id="a4", name="government", when=["", "", "FHA, VA"], then=["0.125"]),
        DecisionRule(id="a5", name="legacy add-on", when=["< 620", "", ""], then=["1.0"], enabled=False),
    ],
)

result = adjustments.run(
    input_data={"fico": 700, "ltv": 85, "program": "FHA"},
    config=RunnableConfig(callbacks=[]),
)
print(result.output)
# {'llpa': 1.125, 'matched_rules': [{'id': 'a1', 'name': 'mid fico'}, {'id': 'a3', 'name': 'high ltv'}, {'id': 'a4', 'name': 'government'}]}

Validation at build time

The rules compile in the constructor, so a bad table never reaches a run — and a YAML file with a bad table fails to load with the same message. Messages name the table, the rule, and the column:

from dynamiq.nodes.operators import DecisionTable
from dynamiq.nodes.types import DecisionRule, NamedField

try:
    DecisionTable(
        name="limits",
        input_columns=[NamedField(name="age", type="int")],
        output_columns=[NamedField(name="limit", type="int")],
        rules=[DecisionRule(name="adults", when=[">= eighteen"], then=["500"])],
    )
except ValueError as error:
    print(error)
# Decision table 'limits', rule 1 (adults), input 'age': expected a number, got 'eighteen'

The constructor also rejects an unknown column type, an output column named matched_rules, two columns on one side sharing a name, a range in a non-numeric column, a range that runs backwards, a comparison with nothing after the operator, and a list with an empty alternative.

At run time only two things can fail: a unique table with overlapping matches, and — as for any node — a timeout or a missing required input in the surrounding flow. Both go through the node's error handling: with behavior=Behavior.RETURN the table fails alone and the rest of the flow keeps running, with the default raise the flow fails naming the node.

Inside a flow

A table is wired like any node. This flow maps the Input node's fields onto the columns and forwards the decision and the matched rules to the Output node:

from dynamiq import Workflow
from dynamiq.flows import Flow
from dynamiq.nodes import InputTransformer
from dynamiq.nodes.node import NodeDependency
from dynamiq.nodes.operators import DecisionTable
from dynamiq.nodes.types import DecisionRule, NamedField
from dynamiq.nodes.utils import Input, Output
from dynamiq.runnables import RunnableConfig

start = Input(id="start", name="start")
table = DecisionTable(
    id="table",
    name="eligibility",
    input_columns=[NamedField(name="fico", type="int")],
    output_columns=[NamedField(name="decision", type="string")],
    rules=[
        DecisionRule(id="r1", name="prime", when=[">= 740"], then=["approve"]),
        DecisionRule(id="r2", name="rest", when=[""], then=["decline"]),
    ],
    depends=[NodeDependency(node=start)],
    input_transformer=InputTransformer(selector={"fico": "$.start.output.fico"}),
)
end = Output(
    id="end",
    name="end",
    depends=[NodeDependency(node=table)],
    input_transformer=InputTransformer(
        selector={"decision": "$.table.output.decision", "rules": "$.table.output.matched_rules"}
    ),
)

workflow = Workflow(flow=Flow(nodes=[start, table, end]))
result = workflow.run(input_data={"fico": 760}, config=RunnableConfig(callbacks=[]))
print(result.output["end"]["output"])
# {'decision': 'approve', 'rules': [{'id': 'r1', 'name': 'prime'}]}

Selectors address nodes by id, so $.table.output.decision reads the node with id="table". The full selector syntax is in Input transformers and Jinja.

YAML

The same table in the YAML format. Cells are strings; an empty string matches anything:

nodes:
  start:
    type: dynamiq.nodes.utils.Input
    name: start

  eligibility:
    type: dynamiq.nodes.operators.DecisionTable
    name: eligibility
    hit_policy: first
    input_columns:
      - { id: fico, name: fico, type: int }
      - { id: ltv, name: ltv, type: int }
      - { id: program, name: program, type: string }
    output_columns:
      - { id: decision, name: decision, type: string }
    rules:
      - { id: e1, name: below floor, when: ["< 580", "", ""], then: [decline] }
      - { id: e2, name: fha floor, when: ["[580..619]", "", "FHA"], then: [review] }
      - { id: e3, name: conforming, when: ["", "<= 97", ""], then: [approve] }
      - { id: e4, name: everything else, when: ["", "", ""], then: [review] }
    depends:
      - node: start
    input_transformer:
      selector:
        fico: $.start.output.fico
        ltv: $.start.output.ltv
        program: $.start.output.program

  end:
    type: dynamiq.nodes.utils.Output
    name: end
    depends:
      - node: eligibility
    input_transformer:
      selector:
        decision: $.eligibility.output.decision
        matched_rules: $.eligibility.output.matched_rules

flows:
  eligibility-flow:
    nodes: [start, eligibility, end]

workflows:
  eligibility-workflow:
    flow: eligibility-flow

The example quotes every when cell, and that is the rule to follow: unquoted, >= 620 opens a YAML folded block scalar, != VA is read as a tag, * as an alias, (0..1] does not parse, [580..619] becomes a list, and FHA, VA inside a flow sequence splits into two cells. A then cell like 0.75 may stay unquoted; the node reads a number where the editor holds text.

Tracing and size

A run's trace records the node's input and output like any other node, so the matched rules are always visible in the run. The node's configuration in the trace carries the first 50 rules and a rules_count rather than the whole table: a 5000-rule table would otherwise be copied into every run it takes part in. Rules compile once per node, and a run coerces each input once and evaluates predicates in table order, so tables of thousands of rows evaluate in a few milliseconds.

On this page