Skip to main content

Online edition for BLOGE 0.9.8-RC1 · facts verified 2026-09-15 · 中文

Appendix F — Decision Table

This appendix is the authoritative quick-reference for the decision_table node type. Every syntax rule, hit-policy guarantee, output shape, and error code listed here is derived directly from bloge source code and conformance fixtures.

Use this appendix when you need to look up exact semantics without re-reading Chapter 5. For a guided walk-through, start with Chapter 5 — Branches That Decide. For a hands-on exercise, see Lab 6 in Chapter 34.


Syntax Cheat Sheet

decision_table <id>(
<param1> = <expr1>,
<param2> = <expr2>,
...
) [hit=<first|unique|any|collect>] [-> <OutputType>] {
rule (<param>: <predicate>, ...) -> <output>
...
[otherwise -> <output>]
}
  • hit= defaults to first when omitted.
  • -> <OutputType> is optional; the compiler infers the type from the first rule's output when absent.
  • Every rule names the parameters it tests inside (...).
  • otherwise is evaluated only after all explicit rule entries fail (or, for collect, it is always appended as an additional item — see §Otherwise Semantics).

Minimal example — hit=first with scalar output:

graph creditScreening {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}

Hit Policy Matrix

PolicyMultiple matchesNo match, no otherwiseException thrown
firstTakes the first match in declaration orderRUNTIME_DECISION_TABLE_NO_MATCHCODE_NO_MATCH
uniqueThrows RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCHRUNTIME_DECISION_TABLE_NO_MATCHCODE_AMBIGUOUS_MATCH / CODE_NO_MATCH
anyAllowed if all matching outputs are equal (Objects.equals); otherwise RUNTIME_DECISION_TABLE_CONFLICTING_MATCHRUNTIME_DECISION_TABLE_NO_MATCHCODE_CONFLICTING_MATCH / CODE_NO_MATCH
collectCollects all matches into output.itemsReturns { items: [] } — no exception

first example — first rule that fires wins:

graph decisionBasicFirst {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}

unique example — at most one rule may fire; multiple matches are an error:

graph decisionBasicUnique {
decision_table credit_band(score = applicant.output.score) hit=unique -> String {
rule (score: score >= 750) -> "prime"
rule (score: 650 <= score < 750) -> "standard"
otherwise -> "manual"
}
}

any example — multiple rules may fire provided their outputs agree:

graph decisionBasicAny {
decision_table approval(score = ctx.score) hit=any -> String {
rule (score: score >= 700) -> "approved"
rule (score: score >= 750) -> "approved"
otherwise -> "manual"
}
}

collect example — all matching rules contribute to output.items:

graph decisionBasicCollect {
decision_table applicable_discounts(score = applicant.output.score) hit=collect -> String {
rule (score: score >= 700) -> "loyalty"
rule (score: score >= 760) -> "premium"
}
}

Otherwise Semantics

Policyotherwise presentotherwise absent
firstFires when no explicit rule matchesNo match → RUNTIME_DECISION_TABLE_NO_MATCH
uniqueFires when no explicit rule matchesNo match → RUNTIME_DECISION_TABLE_NO_MATCH
anyFires when no explicit rule matchesNo match → RUNTIME_DECISION_TABLE_NO_MATCH
collectAlways appended as an additional item, regardless of explicit matchesEmpty match → returns { items: [] }, no exception

The collect asymmetry: For first, unique, and any, otherwise is a fallback that fires only when nothing else matched. For collect, it is an unconditional rule that always appends its output to items. This distinction is flagged by the decision-table/collect-otherwise lint rule (INFO severity) so you can decide whether the behaviour is intentional.


Output Shape Reference

Three shapes are available. All rule bodies (including otherwise) in a single table must use the same shape — mixing shapes is a compile error (COMPILER_DECISION_TABLE_OUTPUT_SHAPE_MISMATCH).

ShapeDeclaration syntaxDownstream access
Scalar-> "value" / -> 3.5<node>.output.value
Named fields-> { rate: 3.5, maxTerm: 30 }<node>.output.rate, <node>.output.maxTerm
Collect itemshit=collect + any output<node>.output.items (a List)

Multi-output (named fields) example:

graph decisionMultiOutput {
decision_table loan_terms(
score = applicant.output.score,
amount = loan.output.amount
) hit=unique -> { rate: Decimal, maxTerm: Int } {
rule (score: score >= 750, amount: amount <= 500000) -> { rate: 3.5, maxTerm: 30 }
otherwise -> { rate: 6.0, maxTerm: 5 }
}
}

The in Operator

in tests set membership inside a rule predicate. There are two forms.

Form A — Literal array

rule (type: type in ["vip", "enterprise", "gov"]) -> "priority"
  • The right-hand side is a literal array: [lit1, lit2, ...].
  • Each element must be a String, Number, or Boolean literal — no expressions, variables, or node paths are allowed.
  • Violating this causes a compile error: COMPILER_DECISION_TABLE_IN_LITERAL_ONLY.
  • An empty array (type in []) never matches any input.

Example:

graph decisionTableInStatic {
decision_table customer_tier(type = ctx.type) hit=first -> String {
rule (type: type in ["vip", "enterprise", "gov"]) -> "priority"
otherwise -> "standard"
}
}

Form B — Dynamic parameter reference

rule (type: type in allowed) -> "priority"
  • The right-hand side is a single declared parameter name that resolves to a Collection at runtime.
  • Deep paths like ctx.allowed, obj.list, or a[0] are not permitted — use the parameter binding in the decision_table(...) head to forward the collection.
  • If the parameter resolves to a non-Collection type at runtime, bloge throws DecisionTableViolationException with code RUNTIME_DECISION_TABLE_INVALID_COLLECTION_PARAM. The exception message includes the line and column of the in expression and the actual runtime type.
  • A null left-hand value always evaluates to false (short-circuit, no exception).

Example:

graph decisionTableInDynamic {
decision_table customer_tier(type = ctx.type, allowed = ctx.allowed) hit=first -> String {
rule (type: type in allowed) -> "priority"
otherwise -> "standard"
}
}

Equality semantics — both forms

Both Form A and Form B use numeric-aware equality internally (evaluateBinaryOp(EQ_EQ)). This means Integer(700) and BigDecimal(700.0) are considered equal. You will never get a silent mismatch due to numeric type differences from different graph nodes.


Common Pitfalls

1. Scope violation — referencing a non-parameter path in a rule

// ❌ COMPILE ERROR: COMPILER_DECISION_TABLE_RULE_SCOPE_VIOLATION
rule (score: applicant.output.score >= 750) -> "prime"

// ✅ bind the value as a parameter first
decision_table credit_band(score = applicant.output.score) hit=unique -> String {
rule (score: score >= 750) -> "prime"
...
}

2. Output shape mismatch

// ❌ COMPILE ERROR: COMPILER_DECISION_TABLE_OUTPUT_SHAPE_MISMATCH
decision_table tier(score = ctx.score) hit=first {
rule (score: score >= 750) -> "prime" // scalar
otherwise -> { label: "manual", flag: true } // named fields — shape mismatch!
}

3. in right-hand side is not a plain parameter name

// ❌ COMPILE ERROR: COMPILER_DECISION_TABLE_IN_INVALID_RIGHT
rule (type: type in ctx.allowed) -> "priority" // deep path not allowed

// ✅ bind it in the parameter list
decision_table tier(type = ctx.type, allowed = ctx.allowed) hit=first -> String {
rule (type: type in allowed) -> "priority"
...
}

4. in right-hand side is not a Collection at runtime

If the parameter bound to the right side of in resolves to a non-Collection value (e.g., a String or Integer), bloge throws:

RUNTIME_DECISION_TABLE_INVALID_COLLECTION_PARAM

The exception message identifies the exact line, column, and the actual runtime type. Fix the upstream node or graph expression that produces the wrong type.

5. collect + otherwise — always appends, never just falls back

// [INFO] decision-table/collect-otherwise
// The otherwise clause here appends "baseline" to items on EVERY run,
// even when other rules also matched. This may be intentional, but verify.
decision_table discounts(score = ctx.score) hit=collect -> String {
rule (score: score >= 700) -> "loyalty"
otherwise -> "baseline" // always appended
}

Runtime Error Codes

All runtime violations are thrown as DecisionTableViolationException. The code field carries one of these frozen wire strings:

Wire stringWhen thrown
RUNTIME_DECISION_TABLE_NO_MATCHfirst, unique, or any — no rule matched and no otherwise is declared
RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCHunique — more than one rule matched
RUNTIME_DECISION_TABLE_CONFLICTING_MATCHany — multiple rules matched with different output values
RUNTIME_DECISION_TABLE_INVALID_COLLECTION_PARAMin <param> — the parameter does not resolve to a Collection

These strings are frozen contracts. Copy them verbatim when matching in application error handlers or test assertions.


Compiler Error Codes

The bloge compiler rejects invalid decision table definitions at compile time. Key error identifiers (used in diagnostics and CI lint output):

IdentifierCause
COMPILER_DECISION_TABLE_IN_LITERAL_ONLYin [...] array contains a non-literal element
COMPILER_DECISION_TABLE_IN_INVALID_RIGHTin <expr> right side is not a single parameter name
COMPILER_DECISION_TABLE_RULE_SCOPE_VIOLATIONA rule predicate references something outside the declared parameters
COMPILER_DECISION_TABLE_OUTPUT_SHAPE_MISMATCHRules use inconsistent output shapes (scalar vs. named)
COMPILER_DECISION_TABLE_OUTPUT_TYPE_INVALIDA named output type annotation contains a field without the required name: Type form
COMPILER_DECISION_TABLE_UNKNOWN_PARAMETERA rule condition names a parameter not declared in the table head
COMPILER_DECISION_TABLE_DUPLICATE_CONDITIONTwo rules have identical conditions
COMPILER_DECISION_TABLE_PARAM_RESERVEDA parameter name collides with a DSL keyword
COMPILER_DECISION_TABLE_PARAM_DUPLICATEDuplicate parameter name in the table head
COMPILER_DECISION_TABLE_PARAM_NODE_CONFLICTA parameter name shadows a graph node name

Minimal Runnable Examples

Example 1 — hit=first with otherwise

graph ex1First {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}

Expected: score=720credit_tier.output.value == "gold".

Example 2 — hit=unique — ambiguous-match error path

graph ex2Unique {
decision_table risk_band(score = ctx.score) hit=unique -> String {
rule (score: score >= 650) -> "eligible"
rule (score: score >= 700) -> "preferred"
// No otherwise — if score >= 700 both rules fire → RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH
}
}

Expected: score=720DecisionTableViolationException with RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH.

Example 3 — hit=collect with named-field output

graph ex3Collect {
decision_table loan_badges(
score = applicant.output.score,
amount = loan.output.amount
) hit=collect -> { label: String, discount: Decimal } {
rule (score: score >= 700) -> { label: "loyalty", discount: 0.02 }
rule (score: score >= 760) -> { label: "premium", discount: 0.05 }
rule (amount: amount <= 100000) -> { label: "small-loan", discount: 0.01 }
}
}

Expected: score=780, amount=80000loan_badges.output.items contains three entries: loyalty, premium, small-loan.


See Also