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
namestrhit_policyDecisionHitPolicy | "first" | "unique" | "collect"aggregationDecisionAggregation | "list" | "sum" | "min" | "max" | "count"input_columnslist[NamedField]output_columnslist[NamedField]ruleslist[DecisionRule]NamedField and DecisionRule come from dynamiq.nodes.types:
| Model | Fields |
|---|---|
NamedField | id (generated when omitted), name, type — one of string, int, float, bool, Any (the default). Any other type fails at construction. |
DecisionRule | id (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.
| Cell | Meaning |
|---|---|
empty, * | any value, including a missing one |
700, FHA, true | equals the literal |
"700" | equals the text 700 — quotes keep a value as text |
>= 620, < 0.8, != VA, == FHA | compares with the literal (= is accepted for ==) |
[620..680], (0..1] | within the numeric range; [ and ] include the bound, ( and ) exclude it |
FHA, VA, USDA | any of the alternatives |
!= FHA, VA | none 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 type | Accepts | Anything else |
|---|---|---|
int, float | numbers, and strings that look like numbers ("700" becomes 700, "1e6" becomes 1000000.0) | unreadable |
bool | booleans, and the strings true / false in any case | unreadable |
string | strings; numbers and booleans are converted to text | unreadable (lists, dicts) |
Any | the 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_policy | Behavior |
|---|---|
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. |
unique | Every 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. |
collect | Every 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
aggregation | Result per output column |
|---|---|
list (default) | The matched cells in table order, None for an empty cell; [] when nothing matched. |
count | The number of matching rules; 0 when nothing matched. |
sum, min, max | The 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-flowThe 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.
Expression
Compute a rate or a label from a table's outputs.
Sub-workflow
Package a decision flow and run it per application inside a Map.
Decision Table node (platform)
The same node in the workflow editor: the rules grid, CSV import, and test-case coverage.
YAML workflows
Loading, dumping, and round-tripping definitions.
Operators
The deterministic control-flow nodes — Choice, Map, Decision Table, Rules, Expression, and Sub-workflow — what each one does and when to reach for it.
Rules
Check a record against a list of rules — inputs read by path, derived values, severities and conditions, effective windows, what a missing value means, build-time validation, and the YAML form.