Packages
A parser and evaluator for FEEL, the Friendly Enough Expression Language of DMN 1.6.
Current section
Files
Jump to
Current section
Files
README.md
# Feel
A parser, type analyzer, and evaluator for **FEEL** (Friendly Enough Expression Language) defined by [DMN 1.6](https://www.omg.org/spec/DMN/), 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](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md#compatibility-snapshot) records compatibility results.
> The [type-system assessment](docs/type-system-analysis.md#2-contracts-and-evidence) 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
- **DMN 1.6:** Decimal arithmetic rounded to 34 digits (via `Decimal`), temporal types (dates, times, and separate days-and-time vs. years-and-months durations, with IANA time zones via `tz`), ranges, contexts, and all 86 standard built-in functions.
- **Prepare Once, Run Many Times:** Separates parsing, name resolution, and static typing (`Feel.prepare/2`) from evaluation (`Feel.run/3`).
- **Dual Execution Backends:**
- **Interpreter** (default): Walks the IR. Generates no code and creates no atoms. Each run reads the OS clock once (`System.os_time/1`) unless `:clock` is set; `now()` and `today()` share that instant. Time-zone data is the `tz` database compiled into the code.
- **BEAM Compiler** (`backend: :beam`): Compiles IR to Erlang abstract format and loads a BEAM module, splitting a long body by the variables the rest of it still reads. The module stays until `Feel.Backend.Beam.release/1`, which takes time in proportion to the number of processes in the VM; its name is an atom, and the BEAM never garbage-collects atoms, so that stays for the life of the VM, released or not. The atom table is fixed in size (1,048,576 entries by default) and a full table crashes the whole VM, not just the expression. Each distinct expression adds at least one atom (the same expression prepared again adds none), so compile a bounded set of expressions that you control, and use the interpreter for external input.
- **Static Refinement Types:** An internal type analysis uses set-theoretic representations to infer intervals, narrow through conditions, flag selected dead branches and remove runtime assertions. Its soundness limitations are documented in the assessment above.
- **Unary Tests:** Decision-table input entries via `Feel.test/4`, and the same tests in an expression via `in`. A decision table is a node of the syntax tree (`Feel.AST`) with its own run (`Feel.Runtime.DecisionTable`); it has no text, and the DMN layer (`dmn`) builds it from a model and gives it to `Feel.prepare_ast/3`.
- **Lenient & Strict Error Handling:** A semantic error evaluates to `null` and is returned as a diagnostic of severity `:error` in `:lenient` mode (the default), or halts the run at that error in `:strict` mode. An `:info` or a `:warning` does not halt a strict run.
- **Camunda Extensions:** Opt-in support for Camunda 8 functions (`is defined`, `trim`, `partition`, etc.), overloads, and flexible date-time formats (`:camunda`).
- **Resource Containment:** Public FEEL calls run preparation and evaluation in BEAM workers with deadlines, process heap policies and bounded transfers. `Feel.Limits` also bounds syntax, calls, iterations and individual collection sizes.
---
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Core Workflow: Prepare and Run](#core-workflow-prepare-and-run)
- [Scopes and Static Analysis](#scopes-and-static-analysis)
- [Unary Tests and Decision Tables](#unary-tests-and-decision-tables)
- [Dual Execution Backends](#dual-execution-backends)
- [Error Handling: Lenient vs. Strict](#error-handling-lenient-vs-strict)
- [Language Features](#language-features)
- [Extensions](#extensions)
- [Untrusted Expressions and Limits](#untrusted-expressions-and-limits)
- [Compliance and Verification](#compliance-and-verification)
- [Documentation and Deep Dives](#documentation-and-deep-dives)
- [Development](#development)
- [License](#license)
---
## Installation
Add `feel` to your dependencies:
```elixir
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`.
```elixir
# 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:
```elixir
# 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
```elixir
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](docs/type-system.md#guarded-recursive-types) 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:
```elixir
# 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:
```elixir
# 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`:
```elixir
{: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:
```elixir
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` (default):** Division by zero, a type mismatch, and a `null` operand of `+`, `-`, `*`, `/`, `**`, or of `<=` and `>=`, evaluate to `null` and come back as a diagnostic of severity `:error`. A `null` operand of `<` or `>` is `null` with no diagnostic. `and`, `or`, and `not` follow three-valued logic and report nothing: `null and true`, `null or false`, and `not(null)` are `null`, but `null and false` is `false` and `null or true` is `true`. An unknown name is a `:warning` and is read from the bindings. A violation of the allowed values under `declarations: :report` is a `:warning` and the value stays. An implicit conversion is an `:info`.
- **`:strict`:** The first diagnostic of severity `:error` ends the run with `{:error, %Feel.Error{kind: :strict}}`. `:info` and `:warning` stay with the value.
```elixir
# 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`:
```elixir
# 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
```elixir
# 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
```elixir
# 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:
```elixir
# 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:
```elixir
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](https://github.com/FeeJai/BDM/blob/main/apps/dmn/README.md#security-model); `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](docs/architecture.md#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:
```elixir
{: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](docs/architecture.md#security-model) for worker ownership, nested policies, transfer accounting and the remaining limits.
Checked while parsing or analyzing:
- `max_source_bytes` (default 100,000): the size of the source.
- `max_nesting_depth` (default 100): how deeply expressions nest, including the nesting of a type (`list<list<number>>` and the parts of a `context<>` or `function<>`).
- `max_name_tokens` (default 64): the tokens of one name the expression introduces (`first name` is two). Scope names and context keys written as strings are outside it.
- `max_narrowing_facts` (default 256): facts about one subject that the analysis has to reapply at each read.
Checked while running, on both backends:
- `max_iterations`: elements taken by `for`, `some`, and `every`.
- `max_call_depth` and `max_calls`: how deeply calls of a function written in the expression may nest, and how many such calls a run makes in all. Calls of built-in functions are not counted by these.
- `max_diagnostics`: diagnostics reported.
- `max_list_length` and `max_string_length`: each constructed list and string, including intermediate results, plus nested returned values, folded constants and range endpoints.
- `regex_match_limit`: backtracking steps for `matches`, `replace`, and `split`.
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
- **DMN Technology Compatibility Kit (TCK):** The [dated report](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md) records the vendored revision, options, result counts and accepted deviations for both runners. Semantic choices and their rationale belong in the [decision record](docs/decisions.md).
- **Camunda documentation examples:** `scripts/camunda_examples/run.sh` fetches the documentation and compares its examples on both backends, with and without extensions. It writes a local report under `tmp/camunda_examples/`; the documentation is an external compatibility corpus, not the FEEL specification. It needs network access and Python with `beautifulsoup4`.
- **Generated property sweeps:** These test whether runtime values fall within inferred types on generated expressions and finite bindings. `mix test` runs deterministic slices; `mix test --only sweep` runs the full generated corpus. The narrowing documentation tables are checked against the analyzer too. See the [sweep workflow](https://github.com/FeeJai/BDM/blob/main/apps/feel/scripts/sweep/README.md) for coverage, commands and oracle limitations. Passing sweeps do not establish general soundness.
---
## Documentation and Deep Dives
Detailed architectural and design documentation is available in `docs/`:
| Document | Topic |
| -------- | ----- |
| [docs/architecture.md](docs/architecture.md) | End-to-end compiler pipeline, stage transitions, and module hierarchy. |
| [docs/compiler-architecture.md](docs/compiler-architecture.md) | Comparison with other compilers and a prioritized architecture roadmap with tradeoffs and validation criteria. |
| [docs/type-system.md](docs/type-system.md) | Specification lattice vs. internal set-theoretic refinement type system. |
| [docs/type-system-analysis.md](docs/type-system-analysis.md) | Type-system assessment, scientific foundations, language comparisons, recommended target design, and open obligations. |
| [docs/narrowing.md](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](docs/decisions.md) | Current design choices and their rationale, organized by topic. |
| [docs/todo.md](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`.
```sh
# 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:
```sh
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`:
```sh
(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](https://github.com/FeeJai/BDM/blob/main/README.md#working-in-it).
To inspect AST and IR pipeline transformations step by step:
```sh
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`.