Feel

A parser, type analyzer, and evaluator for FEEL (Friendly Enough Expression Language) defined by DMN 1.6, written in Elixir.

Status: 0.1.0, active development. The parser and both backends implement most FEEL features, including all 86 standard built-in functions. External function definitions are parsed but rejected during preparation unless an extension adds the function for their kind (D-211; feel_java does this for java). The TCK report records compatibility results. The type-system assessment states the result-check, nested-negation and decision-table contracts, together with the remaining proof-audit and precision work. They do not establish general analyzer soundness.


Highlights


Table of Contents


Installation

Add feel to your dependencies:

def deps do
  [
    {:feel, "~> 0.1.0"}
  ]
end

Runtime dependencies are decimal (IEEE 754 decimal arithmetic, rounded to the 34 digits of FEEL) and tz (the IANA time zone database, compiled into the code).


Quick Start

For one-off evaluations, use Feel.eval/3:

Binding names are strings. A non-string key is an input error; with trusted containment disabled, building the implicit scope raises ArgumentError.

# Numbers are Decimal values
{:ok, result, []} = Feel.eval("1 + 2 * 3")
result == Decimal.new(7)

# Variables are read from string-keyed bindings
{:ok, true, []} =
  Feel.eval("age >= 18 and name != null", %{"age" => Decimal.new(21), "name" => "Ann"})

# Conditional branches (only the selected branch is evaluated)
{:ok, "adult", []} =
  Feel.eval(~s(if age >= 18 then "adult" else "minor"), %{"age" => Decimal.new(21)})

# Mathematical power operator (rounded to 34 digits)
{:ok, root, []} = Feel.eval("2 ** 0.5")
Decimal.to_string(root)
#=> "1.414213562373095048801688724209698"

Core Workflow: Prepare and Run

In production environments, expressions are typically prepared once and evaluated many times with varying inputs:

# 1. Prepare: parse, resolve names, and infer types once.
#    With no scope, "price" and "quantity" are resolved at run time, so the type is :any.
{:ok, expression} = Feel.prepare("price * quantity")

Feel.type(expression)
#=> :any

Feel.free_names(expression)
#=> MapSet.new(["price", "quantity"])

# 2. Run: execute against specific data bindings
{:ok, total, []} =
  Feel.run(expression, %{"price" => Decimal.new("9.99"), "quantity" => Decimal.new(3)})

Scopes and Static Analysis

A Feel.Scope provides the compiler with schema information before runtime data is supplied.

Type Declarations & Unknown Names

scope = Feel.Scope.new(%{"price" => :number, "quantity" => :number})

# Names are checked at prepare time, and the result type is a number
{:ok, expression} = Feel.prepare("price * quantity", scope: scope)
Feel.type(expression)
#=> :number

# Misspelled or unbound names emit warnings by default, or errors if configured
{:ok, misspelled} = Feel.prepare("prise * quantity", scope: scope)
[%Feel.Diagnostic{code: :unknown_name, severity: :warning}] = Feel.warnings(misspelled)

{:error, %Feel.Error{code: :unknown_name}} =
  Feel.prepare("prise * quantity", scope: scope, unknown_names: :error)

Model-defined types are read with Feel.ItemDefinitions. Recursive definitions are rejected by default; recursive: :guarded retains finite graphs and checks complete finite descendants, while the legacy recursive: :any mode deliberately approximates them. See the guarded-type guide for an example, supported cycles, constraints, conversions and conservative function-signature analysis.

Allowed Values Constraints

Allowed values (as defined in DMN Item Definitions) restrict valid input domains:

# Constrain age to [0..150]
scope = Feel.Scope.new(%{"age" => :number}, allowed_values: %{"age" => "[0..150]"})
{:ok, expression} = Feel.prepare("age + 1", scope: scope)

# An out-of-range value returns null with a diagnostic
{:ok, nil, [%Feel.Diagnostic{code: :value_not_allowed}]} =
  Feel.run(expression, %{"age" => Decimal.new(200)})

# Stances are :enforce (default), :enforce_wide, :trust, :audit, :report, and :ignore.
# :report keeps the value and warns. :enforce makes it null. :audit keeps it and reports
# an error. :trust and :ignore leave the value unchecked. :enforce_wide checks as :enforce
# does and leaves the type as wide as the declaration.
{:ok, expression} = Feel.prepare("age + 1", scope: scope, declarations: :report)
{:ok, _val, [%Feel.Diagnostic{code: :value_not_allowed, severity: :warning}]} =
  Feel.run(expression, %{"age" => Decimal.new(200)})

# The members of a value are constrained by the steps down to them: a key of a context,
# :each for the elements of a list. Both are narrowed on (`person.age` is a number in
# [0..150]), and the value of a run with a member that is not allowed is null.
person_scope =
  Feel.Scope.new(%{"person" => {:context, %{"age" => :number}}},
    allowed_values: %{{"person", ["age"]} => "[0..150]"}
  )

{:ok, expression} = Feel.prepare("person.age", scope: person_scope)
{:ok, nil, [%Feel.Diagnostic{code: :value_not_allowed} | _]} =
  Feel.run(expression, %{"person" => %{"age" => Decimal.new(200)}})

# Constrain the result type and allowed values
scope = Feel.Scope.put_result(scope, result_type: :number, result_allowed_values: "<= 200")
{:ok, expression} = Feel.prepare("age * 2", scope: scope)
{:ok, nil, [%Feel.Diagnostic{message: "the result is not one of its allowed values"}]} =
  Feel.run(expression, %{"age" => Decimal.new(120)})

Unary Tests and Decision Tables

FEEL unary tests express conditions against an implicit input, as used in DMN decision table rule entries:

# Prepare unary test entries
{:ok, tests} = Feel.prepare("[18..65), > 80", entry: :unary_tests)

{:ok, true, []} = Feel.test(tests, Decimal.new(21))
{:ok, false, []} = Feel.test(tests, Decimal.new(70))

# Null input handling
{:ok, false, [%Feel.Diagnostic{code: :null_operand}]} = Feel.test(tests, nil)

# Typed input validation
{:ok, typed} = Feel.prepare("> 5", entry: :unary_tests, input: :number)
{:ok, false, [%Feel.Diagnostic{code: :type_mismatch}]} = Feel.test(typed, "five")

The same tests can be written in an expression with the in operator. Feel.test/4 turns a test that comes out null into false; in keeps the null:

{:ok, true, []}  = Feel.eval("age in [18..65)", %{"age" => Decimal.new(21)})
{:ok, false, []} = Feel.eval("age in (< 18, >= 65)", %{"age" => Decimal.new(21)})
{:ok, nil, [%Feel.Diagnostic{code: :null_operand}]} = Feel.eval("age in [18..65)", %{"age" => nil})

Dual Execution Backends

feel provides two backends. Equivalence tests compare their returned values and diagnostics on shared examples. This is finite regression coverage, not a guarantee for every expression.

Backend Selection Characteristics Best Used For
Interpreter backend: :interpreter (default) Tree-walking evaluator. No generated code and no new atoms. Each run reads the OS clock once unless :clock is set. Dynamic, ad-hoc, or untrusted expressions.
BEAM Compiler backend: :beam, sandbox: false Compiles IR to a loaded BEAM module. release/1 unloads the module, but its atom is never freed (each distinct expression costs at least one atom, and the atom table is fixed in size). Past max_generated_statements, preparation fails with :too_large_to_compile. The same expression, executed many times, from a bounded set of expressions you control.

Using the BEAM backend:

scope = Feel.Scope.new(%{"price" => :number, "quantity" => :number})
{:ok, expression} = Feel.prepare("price * quantity", scope: scope, backend: :beam, sandbox: false)

{:ok, total, []} =
  Feel.run(expression, %{"price" => Decimal.new("9.99"), "quantity" => Decimal.new(3)}, sandbox: false)

# Unload the BEAM module when the expression is discarded (its atom is never freed)
Feel.Backend.Beam.release(expression.executable)

Error Handling: Lenient vs. Strict

In accordance with DMN 1.6, semantic errors do not crash evaluation:

# Lenient mode: nil, and a diagnostic whose severity is :error
{:ok, nil, [%Feel.Diagnostic{code: :division_by_zero, severity: :error}]} = Feel.eval("1 / 0")

# Strict mode: the same error halts the run
{:error, %Feel.Error{kind: :strict, code: :division_by_zero}} =
  Feel.eval("1 / 0", %{}, mode: :strict)

Language Features

Contexts and Entry Ordering

Context entries can reference other keys in the same context, in any order: an entry may read a key written later in the text, and a cycle gives null with an error. By default a context in the result is a plain Elixir map with string keys. The order of the entries is kept inside the run (so get entries lists them as written) and can be kept in the result with contexts: :ordered:

# Context entries reference sibling keys
{:ok, %{"total" => total}, []} = Feel.eval("{total: price * qty, price: 9.99, qty: 3}")
total == Decimal.new("29.97")

# Entry ordering
{:ok, entries, []} = Feel.eval("get entries({b: 1, a: 2})")
Enum.map(entries, & &1["key"])
#=> ["b", "a"]

{:ok, context, []} = Feel.eval("{b: 1, a: 2}", %{}, contexts: :ordered)
Feel.Value.Context.keys(context)
#=> ["b", "a"]

Paths and List Filters

# Path navigation into contexts and date properties
scope = Feel.Scope.new(%{"person" => {:context, %{"name" => :string, "age" => :number}}})
{:ok, expression} = Feel.prepare("if person.age >= 18 then person.age else 0", scope: scope)
{:ok, thirty, []} = Feel.run(expression, %{"person" => %{"name" => "Ann", "age" => Decimal.new(30)}})

{:ok, year, []} = Feel.eval(~s|date("2024-01-31").year|)
year == Decimal.new(2024)

# List filtering
scope = Feel.Scope.new(%{"people" => {:list, {:context, %{"name" => :string, "age" => :number}}}})
{:ok, expression} = Feel.prepare("people[age > 18].name", scope: scope)

# A specification type includes null, so the public type does not mention it.
# The type of the IR node is "list<string | Null> | Null".
Feel.type(expression)
#=> {:list, :string}

people = [%{"name" => "Ann", "age" => Decimal.new(34)}, %{"name" => "Bob", "age" => Decimal.new(12)}]
{:ok, ["Ann"], []} = Feel.run(expression, %{"people" => people})

Dialects and Parsing

# Parse AST directly without type analysis
{:ok, ast} = Feel.parse("for x in [1, 2] return x * 2")

# S-FEEL is a syntax restriction (DMN 1.6, clause 9). Grouping parentheses and null
# belong to the default :practical grammar; :literal follows the printed grammar.
{:ok, _} = Feel.parse("(age + 1) * 2 >= 18", dialect: :s_feel)
{:error, %Feel.Error{kind: :syntax, code: :outside_s_feel}} =
  Feel.parse("if age > 18 then 1 else 2", dialect: :s_feel)

Extensions

Extensions add vendor-specific functions or relaxed spellings without changing standard DMN 1.6 semantics. Extensions are enabled per prepared expression with the :extensions option:

# Camunda extension enables helper functions like `trim`
{:ok, "a", []} = Feel.eval(~s|trim("  a ")|, %{}, extensions: [:camunda])
{:ok, nil, _}   = Feel.eval(~s|trim("  a ")|)

# Safe path inspection with `is defined` (even in strict mode)
{:ok, false, []} =
  Feel.eval("is defined(order.total)", %{"order" => %{}}, extensions: [:camunda], mode: :strict)

# Permissive temporal spellings emit informational diagnostics
{:ok, %Feel.Value.Time{hour: 13, minute: 30}, [%Feel.Diagnostic{code: :temporal_spelling, severity: :info}]} =
  Feel.eval(~s|time("13:30")|, %{}, extensions: [:camunda])

{:ok, expression} = Feel.prepare("trim(name)", extensions: [:camunda])
{:ok, "Ann", []}  = Feel.run(expression, %{"name" => " Ann "})

The :camunda suite comprises three modular extensions (Feel.Extension.registry/0):

Extension Capabilities Added
:camunda_functions is defined, get or else, assert, is empty, is blank, trim, partition, duplicate values, last day of month, to base64, from base64, to json, from json, extract, uuid, and random number
:camunda_overloads Additional signatures for standard functions: number(from, grouping separator), date and time(date and time, timezone), string join with prefix/suffix, get value with key path list, nested context put, and substring past end-of-string
:camunda_temporal_formats Times or date-times without seconds, ISO T delimiters, and Java-style timezone bracket notation (2018-04-29T09:30:00+02:00[Europe/Berlin])

Custom extensions can be created by implementing the Feel.Extension behaviour. An extension that only adds data (it never returns a function, and a diagnostic it makes has no origin) can say so with the optional data_only?/0; a host that copies runs into workers, such as dmn, then sends less with each call. An extension that does not say so is treated as able to return a function written elsewhere. feel_java declares this.


Untrusted Expressions and Limits

Public Feel calls run preparation and execution in monitored BEAM workers by default. They return ordinary FEEL results and prepared expressions:

guard = [timeout: 1_000, max_result_bytes: 10_485_760]
{:ok, expression} = Feel.prepare("x + 1", guard: guard)
Feel.run(expression, %{"x" => Decimal.new(2)}, guard: guard)
Feel.eval("x + 1", %{"x" => Decimal.new(2)}, guard: guard)

eval/3 includes preparation, execution, context conversion and diagnostics in one deadline. prepare/2 returns an ordinary Feel.Expression; every run/3 needs its own protection. Sandbox preparation and execution accept only the interpreter. BEAM compilation creates global atoms and modules that worker termination cannot reclaim, so use it for a bounded set of expressions you control. The generation limit is checked before a distinct module-name atom is created; it does not reclaim names from successful compilations. DMN uses the same worker through its ordinary public APIs; Dmn.compile(model, backend: :beam) is the caller's choice for models it trusts and is accepted in that worker, which does not reclaim the modules and atoms it loads either.

guard: option Default Bounds
timeout 5,000 ms The complete operation, including preparation, result checking and sending
max_memory 134,217,728 bytes (128 MiB) Process heap policy, including shared binaries at garbage collection
max_input_bytes max_memory Conservative estimate of captured input copied into the worker
max_result_bytes max_memory Conservative estimate of the returned term, including diagnostics

The same defaults apply to parsing, preparation and evaluation. A larger artifact or a preparation that needs more time must use an explicit guard: allowance; later calls still get a fresh policy. These are per-operation limits, not a concurrency limit or an instantaneous total-VM memory ceiling. Containment is not free: a contained call of a small expression takes about 9 µs compared with 0.3 µs with sandbox: false (apps/feel/scripts/bench_sandbox.exs, Apple M1 Pro), because a process must be started and a closure copied and checked, and the cost grows with the bindings and the result that cross the boundary.

Timeout and transfer failures return %Feel.Error{kind: :limit} with codes :timeout, :input_too_large or :result_too_large. Unexpected worker termination returns %Feel.Error{kind: :worker, code: :worker_failed}. If the caller dies, the work it owns is cancelled. The first diagnostic's details.reason records the observed worker outcome, such as {:exit, :killed}, {:exit, {:controller, :killed}} or {:raised, exception}. A heap-cap termination can produce {:exit, :killed}, but an explicit kill can produce the same reason; the library does not infer :memory from it. Reasons whose conservative transfer estimate exceeds 4,096 bytes are replaced with reason: :omitted, reason_omitted: :too_large. Fatal worker monitor failures are bounded before they are forwarded to the caller: their complete error envelope must fit both 4,096 bytes and max_result_bytes, otherwise only that omission marker is returned. If even the marker cannot fit, the result is :result_too_large. Ordinary results, caught exits and exceptions keep their complete-result check; timeout and transfer-limit codes stay distinct. Use sandbox: false for the trusted opt-out; guard: nil does not disable containment.

This is resource containment within the host VM. A heap policy is checked at garbage collection; it is not an instantaneous or whole-VM memory ceiling. The preflight, which decides what is copied into the worker, runs in the caller with bounded traversal and checks the deadline cooperatively, including during the transfer-size walk. Expiry returns :timeout even if the byte allowance would also be exhausted. This is not scheduler preemption and does not bound the duration of an individual VM operation. A deadline cannot interrupt arbitrary non-yielding native code. Host functions, extensions and import resolvers are trusted capabilities: terminating a worker cannot undo their effects or contain independent processes they spawn. Keep access to files, networks and external runtimes behind explicit host policies. Raw VM link/monitor signals arrive before their reasons can be sanitized; host callbacks must keep exit reasons bounded. Their initial receipt is outside the worker's heap policy. The application must bound concurrency, accepted input and retained prepared artifacts. Transfer checks and copying grow with the captured bindings and returned data; large inputs can dominate an otherwise short evaluation. Prepared run/3 and test/4 select the compiler's required bindings when their exact canonical keys are available, preserving the implicit unary input ?. An alternate spelling, absent dependency or executable without that metadata keeps the complete binding map. Declaration and allowed-value dependencies are retained even when optimization removes a body read. eval/3 still needs its bindings for scope inference and preparation.

max_input_bytes bounds what is actually sent to the worker. A large unused value omitted from that payload can therefore be accepted; a used value, captured callback environment or returned value still faces its full transfer check. Selection counts toward the same deadline and uses a bounded, shallow caller-side walk. It does not evaluate FEEL, invoke callbacks or perform type conversion. Transfer traversal uses an explicit continuation stack and a byte budget, so a deeply represented expression is not rejected merely for exceeding an internal transfer depth. Parser, call-depth, heap and value-size limits remain in force.

For trusted input, sandbox: false skips the whole-operation worker and transfer checks. It does not disable Feel.Limits, nor can it escape an enclosing worker. The flag applies to each call; it is not stored in a prepared expression. BEAM preparation and execution require this explicit opt-out:

{:ok, expression} = Feel.prepare("x + 1", backend: :beam, sandbox: false)
Feel.run(expression, %{"x" => Decimal.new(2)}, sandbox: false)

Feel.parse/2, prepare_ast/3, test/4 and the raising variants follow the same default. Keep Feel.Limits enabled as well. Trusted calls keep the structural, call, iteration, diagnostic and per-value limits below, but have no whole-call deadline. Iteration and call counters cannot bound a single expensive built-in. See the security model in the architecture for worker ownership, nested policies, transfer accounting and the remaining limits.

Checked while parsing or analyzing:

Checked while running, on both backends:

There is no cumulative allocation quota. Sandboxed operations use the worker heap policy for live process memory; it is checked at garbage collection and is not a whole-VM ceiling. Trusted calls with sandbox: false keep the per-value checks but have no worker memory or deadline protection.

max_generated_statements (default 10,000) applies to backend: :beam. Past it, prepare/2 returns {:error, %Feel.Error{kind: :limit, code: :too_large_to_compile}}. The interpreter generates no statements, so the same source prepares there when the caller selects that backend.

New generated module names also have a VM-lifetime quota of 50,000, configurable through :feel, :max_generated_modules. A refusal returns :too_many_generated_modules before atom creation. Existing names can be reused at the limit. Unloading code and restarting the application do not refund the quota; only a VM restart reclaims the atoms. Hosts still bound concurrent compilation, retained code and other sources of atoms.


Compliance and Verification


Documentation and Deep Dives

Detailed architectural and design documentation is available in docs/:

Document Topic
docs/architecture.md End-to-end compiler pipeline, stage transitions, and module hierarchy.
docs/compiler-architecture.md Comparison with other compilers and a prioritized architecture roadmap with tradeoffs and validation criteria.
docs/type-system.md Specification lattice vs. internal set-theoretic refinement type system.
docs/type-system-analysis.md Type-system assessment, scientific foundations, language comparisons, recommended target design, and open obligations.
docs/narrowing.md Static condition narrowing: the facts a condition gives, where they hold, and what they change in the types, warnings, and optimizer.
docs/decisions.md Current design choices and their rationale, organized by topic.
docs/todo.md Detailed open cases; current priorities are in the type-system and compiler assessments.

Development

Development is pinned to Erlang/OTP 29 and Elixir 1.20 in .tool-versions. mix.exs requires Elixir ~> 1.20.

# Fetch dependencies
mix deps.get

# Formatting and style checks
mix format --check-formatted
mix credo --strict

# Compile with warnings as errors
mix compile --warnings-as-errors

# Run tests and verify test coverage (enforces >= 95% floor)
mix test
mix test --cover

# Type analysis
mix dialyzer

# Build documentation
MIX_ENV=dev mix docs --warnings-as-errors

Running All Quality Gates

scripts/check.sh runs format, compile (warnings as errors), Credo, the Java quality gate, unused-lockfile and Hex audit checks, docs, soundness sweeps, Dialyzer, tests and both TCK runners:

scripts/check.sh             # Full test and verification suite, the steps at the same time
scripts/check.sh --fast      # Quick checks (format, compile, credo)
scripts/check.sh --only test # Run a specific step
scripts/check.sh --serial    # One step after the other, as CI does

Each step's line shows the wall-clock seconds and the processor seconds the step used. The processor time is the cost; the wall-clock time also includes waiting for the rest of the machine.

For performance comparisons, mix run scripts/bench.exs --quick --sandbox measures protected interpreter public calls; --no-sandbox measures the trusted engine and is the default. Both include the same workloads. Split preparation phases stay direct, and BEAM measurements always require trusted mode. --check expectations describe engine work; a sandboxed call can additionally grow with the size of an argument it must copy. Save each mode to a separate --json file. The focused MIX_ENV=test mix run apps/feel/scripts/bench_sandbox.exs, run from the umbrella root, compares prepared FEEL/DMN runs and complete public preparation/compilation with and without the boundary.

The run suite separates execution from preparation and module lifetime:

Measurement What is timed
Interpreter preparation One public Feel.prepare/2 call
BEAM cold preparation One public Feel.prepare/2 call with its generated module absent: parsing, analysis, optimization, code generation, compilation and loading; release is timed separately
BEAM warm preparation The same call while that module is already loaded: parsing, analysis and code generation still run, but the loader reuses the module
BEAM release Feel.Backend.Beam.release/1, including code deletion and purging

Cold means module absence in an already running VM, not a fresh VM or a cold Erlang compiler. Cold and release figures are medians of seven batches of individually timed calls; warm preparation uses the usual seven timed batches. Every cold cycle retires its module before the next preparation, and warm measurements keep the module loaded throughout. Release can grow with the VM's process count. cold break-even uses the additional cold preparation cost divided by the execution time saved; release is excluded and must be budgeted separately. Preparing an already loaded module is distinct from reusing a prepared expression, which avoids preparation entirely.

JSON reports run/NAME/prepare_interp_ns, prepare_beam_cold_ns, prepare_beam_warm_ns and release_beam_ns. The former prepare_beam_ns combined preparation and release and is removed; it must not be compared directly with the new cold figure. The table suite reports table/BACKEND/prepare_first_pass_us and prepare_warm_ns, plus table/beam/release_us. Its first pass starts with generated modules absent, but repeated cells reuse modules within that pass, so it is labelled first pass, shared modules, not a cold per-cell measurement. Warm preparation keeps those modules, and release retires each distinct module once, including the batch's traversal and deduplication. The old table prepare_us keys are replaced by the explicit prepare_first_pass_us keys.

The test step includes a slice of every soundness sweep (test/feel/sweep/) unless the sweeps step also runs, which runs them whole. The sweeps step runs all of them, as MIX_ENV=test mix test --only sweep test/feel/sweep; its output is in tmp/check/sweeps.log in the root. The scripts in scripts/sweep/ run the same sweeps with a report of their own and have options for comparing two trees.

To run the TCK from apps/feel:

(cd ../bdm_tck && mix run scripts/tck.exs)                       # FEEL expressions
(cd ../bdm_tck && mix run scripts/tck.exs --extensions camunda)  # FEEL with extensions
(cd ../bdm_tck && mix run scripts/dmn_tck.exs)                   # Complete DMN models

Opening a pull request runs format, a forced compile and the tests without sweep slices. The rest of CI is opt-in: the ci label, a draft marked ready, or a workflow dispatch. See the root CI policy.

To inspect AST and IR pipeline transformations step by step:

mix run scripts/steps.exs "price * quantity" --bind price=2 --bind quantity=3

License

feel is released under the GNU Affero General Public License v3.0 only. The text is LICENSE. The vendored DMN TCK cases keep their own license, apps/bdm_tck/test/fixtures/tck/LICENSE-ASL-2.0.txt.