> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prodexlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The Expression Language

> ProDex's Excel-style formula language for making simulation models dynamic and data-driven.

## Overview

The Expression Language (or DSL) is how you make ProDex models dynamic. Wherever you'd expect to enter a fixed number (routing conditions, queue priorities, resource requirements, entity attribute values, event triggers, and the *parameters* of any distribution — processing times, arrivals, changeover durations, and the rest), you can enter an expression instead. Expressions reference simulation state, entity attributes, constants, lookup tables, and state variables, so your model responds to the data flowing through it rather than behaving identically for every entity.

A user who only enters fixed numbers is using a small fraction of what the Modeler can do. Learning the expression language is what turns a static flow diagram into a living model of your operations.

## Where Expressions Appear

Most numeric and logical fields in a component configuration accept either a literal value or an expression. Common places:

* **Arrival logic** on a Source (an inter-arrival time, not a rate): can depend on simulation time, schedule, or state
* **Routing conditions** on a Router: branches based on entity attributes, resource availability, or queue state
* **Routing weights** on a Router: the relative weight of each branch when routing probabilistically, computed per entity
* **Resource requirements** on a Process: how many units of a resource are needed, conditional on the entity
* **Changeover time** on a Resource: how long a switchover takes, optionally depending on what the resource is switching between (see [Changeover-Only Identifiers](#changeover-only-identifiers))
* **Queue priority** on a Buffer: sorts waiting entities by a computed score
* **Resource allocation priority**: picks among competing demands when capacity is constrained
* **Event Hook condition** and Action arguments: see [Event Hooks](/reference/events#event-hooks)
* **Event Listener condition** and Action arguments: see [Event Listeners](/reference/events)
* **Scheduled action condition**: gate whether a scheduled `assign` / `emit` fires (see [Schedules](/reference/schedules))
* **Entity attribute assignments** on a Source, Transformer, Combiner, or Separator output: compute initial or updated attribute values
* **Combiner `batch_expr`**: the boolean trigger that closes a dynamic-size batch
* **Separator output count**: DSL expression returning the number of outputs per input
* **Distribution parameters**: every numeric field that accepts a distribution has a `</>` toggle that switches the parameter input into expression mode. See [Distributions vs. Expressions](#distributions-vs-expressions) below.

## Syntax Basics

Expressions follow an Excel-like syntax: numeric literals, string literals in single OR double quotes, function calls in `FUNCTION(args)` form, arithmetic operators, comparison operators, and logical operators.

```
5
"TypeA"
'TypeB'
FUNCTION(arg1, arg2)
a + b * c
IF(condition, then_value, else_value)
priority > 5 AND qc_passed
```

Boolean literals are case-insensitive: `TRUE`, `True`, `true`, `FALSE`, `False`, `false` all parse. Numeric literals cover integers, decimals, negatives, and scientific notation (`1e-10`).

### Operator Precedence

Operator precedence, highest to lowest:

1. **Unary `-`**
2. **Multiplication, division**: `*`, `/`
3. **Addition, subtraction**: `+`, `-`
4. **Comparison**: `<`, `<=`, `>`, `>=`, `=`, `==`, `!=`
5. **`NOT`**
6. **`AND`**
7. **`OR`**

`AND`, `OR`, and `NOT` are **infix and prefix operators**, not functions: write `a AND b`, `a OR b`, `NOT a`, never `AND(a, b)`. Both `=` and `==` are accepted as equality operators. Use parentheses for explicit grouping when you want to override precedence.

<Note>
  **`NOT` binds *looser* than comparisons**, not tighter: `NOT a = b` parses as `NOT (a = b)`, never `(NOT a) = b`. This is usually what you want, but it surprises people coming from languages where `!` binds tightly. Parenthesize when in doubt.
</Note>

## Expression Contexts

The **context** an expression runs in determines which identifiers are available to reference. Getting context wrong produces validation errors like *"identifier not available here"* that are confusing without understanding the model. There are three contexts.

### No-Entity Context

Runs without a specific entity in scope. Available identifiers:

* [Built-in state variables](#built-in-state-variables) like `SIM_TIME`, `SIM_DURATION`
* [Component query functions](#component-query-functions) like `BUFFER_LEVEL(...)`, `RESOURCE_AVAILABLE(...)`
* [Constants](/reference/constants-and-lookups) by name
* [State variables](/reference/events#state-variables) by name
* `SELF`: when the expression is on an event hook or event listener (see [SELF availability](#self-availability) below)
* Entity attributes are **not** available

Used in: Source arrival logic (before any entity exists), Event Listener conditions and actions (listeners always run no-entity), Resource events, scheduled actions, and global state checks.

### Single-Entity Context

Runs with one specific entity in scope. Everything from no-entity context, plus:

* **Entity attributes by bare name**: `priority`, `product_class`, `weight`
* `ENTITY_TYPE`, `ENTITY_AGE`

Used in: Process processing time, Router conditions, Transformer attribute assignments, per-entity Event Hooks (e.g., `entity created`, `process started`), and similar places where one entity is clearly in scope.

### Multi-Entity Context

Runs with multiple entities in scope. The **seven** multi-entity surfaces are:

* **Combiner attribute assignment**: output entity attributes derived from inputs
* **Combiner `batch_expr`**: the boolean trigger that closes a dynamic batch
* **Combiner `on_batch_assembled` hook**: fires once per assembled batch with all inputs in scope
* **Separator `on_batch_created` hook**: fires once per split with all outputs in scope
* **Buffer `release_entity` quantity**: how many held entities to release, computed over the buffer's contents
* **Buffer `on_buffer_contents_changed` hook**: fires with the buffer's current contents in scope
* **Station `on_station_contents_changed` hook**: fires with the station's current contents in scope

In any multi-entity context, bare attribute references like `priority` are ambiguous (*which entity's*?). You must wrap them in an [aggregation function](#aggregation-functions): `MAX(priority) > 5`, `ANY(qc_passed)`.

<Note>
  **`ENTITY_TYPE` and `ENTITY_AGE` follow the same rule in multi-entity context**: they're available only *inside* an aggregation body — `MODE(ENTITY_TYPE)`, `SUM(ENTITY_AGE)`, `COUNT(1, filter:=ENTITY_TYPE == "tube")` — never at the top level of the expression.
</Note>

### Which Field Runs in Which Context

| Surface                                                         | Context                              |
| --------------------------------------------------------------- | ------------------------------------ |
| Source `arrival_logic`                                          | No-entity                            |
| Event Listener condition / action args                          | No-entity (always)                   |
| Scheduled action condition / args                               | No-entity                            |
| Process `processing_time`, resource requirements                | Single-entity                        |
| Router rules                                                    | Single-entity                        |
| Buffer queue-priority key                                       | Single-entity (per waiting entity)   |
| Resource allocation-priority key                                | Single-entity (per competing entity) |
| Source / Separator / Transformer attribute assignments          | Single-entity                        |
| Per-entity event hooks (`entity created`, `process started`, …) | Single-entity                        |
| The seven surfaces listed above                                 | Multi-entity                         |

## Built-in State Variables

* `SIM_TIME`: current simulation time
* `SIM_DURATION`: configured simulation duration
* `ENTITY_TYPE`: entity type slug of the current entity (single-entity context, or inside aggregation bodies in multi-entity context). Returns the **slug** (e.g., `widget`), not the display name (e.g., `Widget`).
* `ENTITY_AGE`: how long the current entity has existed, i.e. `SIM_TIME − creation_time` (single-entity context, or inside aggregation bodies)
* `SELF`: the current component's `id` (see [SELF availability](#self-availability))

### SELF Availability

`SELF` is **only** valid inside event hook and event listener expressions. It is **not** available in:

* Scheduled actions
* Component configuration fields outside of hooks/listeners (processing\_time, arrival\_logic, routing rules, queue priority, resource allocation priority, attribute assignments)

If you need to reference a specific component from outside a hook, use that component's name explicitly inside a [component query function](#component-query-functions).

### Changeover-Only Identifiers

A Resource's **changeover time** expression gets four identifiers that exist nowhere else in the DSL:

* `FROM_ENTITY_TYPE` / `TO_ENTITY_TYPE`: the entity type slugs on each side of the changeover
* `FROM_ENTITY_ATTRIBUTE` / `TO_ENTITY_ATTRIBUTE`: attribute access on the outgoing and incoming entities

Use them to make changeover duration depend on what the resource is switching between — e.g., a paint booth whose cleanup takes longer when the color family changes.

## Component Query Functions

These functions read live simulation state for a specific component. The first argument is a **text expression that evaluates to the component's `id`** (the schema id, not the display name) — a string literal is the common case, but an entity attribute holding an id, `SELF`, or a `CONCAT(...)` all work.

* `BUFFER_LEVEL(buffer_id)`: current number of entities in the named buffer
* `BUFFER_CAPACITY(buffer_id)`: configured capacity of the named buffer (returns infinity for an unbounded buffer)
* `RESOURCE_AVAILABLE(resource_id)`: currently available units of the named resource
* `RESOURCE_CAPACITY(resource_id)`: configured capacity of the named resource
* `STATION_WIP(station_id [, entity_type])`: work in progress at the named station; optional second argument filters to a specific entity type

<Warning>
  **Component query functions take the component `id`, not the display name shown in the Modeler.** If your buffer's display name is *"WIP Buffer"* and its id is `wip-buffer`, you must write `BUFFER_LEVEL("wip-buffer")`. Passing the display name silently returns `0` and your model behaves as if the buffer is always empty.
</Warning>

### Default Value

All component query functions accept an optional `default:=` keyword argument that overrides the fallback:

```
BUFFER_LEVEL("wip-buffer", default:=-1)
```

The fallback fires whenever the id doesn't resolve to a component **of the right kind** — a typo, a display name, or a valid id that belongs to a different component type (`BUFFER_LEVEL` pointed at a resource). For `STATION_WIP` it also fires when the entity-type filter names a type with no capacity entry at that station. Without `default:=`, the fallback is numeric `0`. Unlike LOOKUP, component queries do **not** return type-specific zero (`""` or `FALSE`), only numbers.

## Custom State Variables

[State variables](/reference/events#state-variables) defined on the model are readable by their bare SCREAMING\_SNAKE\_CASE name in any DSL context, no-entity, single-entity, or multi-entity:

```
current_shift_efficiency
UNITS_COMPLETED + 1
IF(SHIFT_MODE == "night", base_time * 1.2, base_time)
```

State variables are read directly in expressions and written via the `assign` action on event hooks, event listeners, or scheduled actions. They persist across entities and across the entire run.

State variables are declared in the model's `state_variables` array, each with a `name`, a `type` (`number`, `text`, or `boolean`), and a matching `initial_value`. Names must match `^[A-Z][A-Z0-9_]*$` (SCREAMING\_SNAKE\_CASE) and must not collide — **case-insensitively** — with reserved DSL identifiers, constants, lookup tables, component ids, or entity type slugs (`sim_time` collides with `SIM_TIME`).

For the distinction between state variables (model-level, mutable) and entity attributes (per-entity, move with the entity), see [Entity Attributes](#entity-attributes) below.

## Entity Attributes

Entities carry **typed attributes** through the flow: `boolean`, `number`, `text`, `text_list`, `number_list`. Despite the names, `text_list` and `number_list` are **single-valued discrete enums** (the entity holds one value from a declared set), not collections — and they reject the DSL Expression assignment strategy at validation. Reference the current entity's attributes **by their bare name** in any expression that runs in a single-entity context:

```
priority
product_class
weight
```

No `self.` prefix: the entity in scope is implicit. Expressions reference attributes directly by the name defined on the entity type.

In multi-entity contexts, bare attribute names refer to the set of entities and must be wrapped in an aggregation function like `SUM(weight)` or `MAX(priority)`. See [Aggregation Functions](#aggregation-functions).

Entity attributes are set on a Source, can be modified by a Transformer, and can be read from any expression downstream. They persist with the entity through the entire flow. See [Entities](/reference/entities) for the full attribute type system and assignment strategies.

## Distributions vs. Expressions

A common confusion: **distributions are not DSL functions.** You can't write `NORMAL(60, 10)` as an expression. Distributions are picked from a **dropdown** on every numeric field that supports them: Normal, Uniform, Triangular, Beta, and the rest of the eleven options (including `lognormal_from_mean_cv`, a distinct primitive parameterized by mean and coefficient of variation rather than the underlying normal's parameters). Selecting one reveals the parameter fields beneath the picker.

Each parameter field has a `</>` toggle that switches it into expression mode: that's where the DSL comes in. The distribution shape stays static; the parameter values become dynamic.

The bridge in the other direction is the **Fixed** distribution: when a field expects a distribution but you want a fully computed value, pick Fixed and put the DSL expression in its Value parameter. That's the documented way to make, say, a process time exactly equal to `LOOKUP(cycle_times, ENTITY_TYPE)` with no randomness on top.

For example, instead of a hardcoded mean of `60`, you can flip the Mean field to expression mode and type:

```
base_time + weight * 0.5
```

Or have the mean depend on a lookup table by entity type:

```
LOOKUP(cycle_times_by_product, ENTITY_TYPE) * complexity_factor
```

The Standard Deviation field can stay as a literal `10`, or also be an expression. See [Distributions](/reference/distributions) for the full list of supported distribution types.

## Function Catalog

The complete built-in function set, grouped the way you'll reach for it.

### Control Flow

* `IF(condition, then, else)`: branch between two values. Both branches must return the same type.
* `AND`, `OR`, `NOT`: logical combinations (infix/prefix operators, not functions)
* Comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `=`

### Math

* `ABS(x)`: absolute value
* `POW(base, exp)`: `base` raised to `exp`
* `MOD(a, b)`: remainder of `a` divided by `b`
* `FLOOR(x)`: round down (toward −∞)
* `CEIL(x)`: round up (toward +∞)
* `ROUND(x, ndigits:=0)`: round to `ndigits` decimal places, **half-to-even** (banker's rounding)
* `MIN_OF(a, b)` / `MAX_OF(a, b)`: smaller / larger of two numbers — scalar functions, distinct from the `MIN` / `MAX` aggregations
* `CLAMP(value, lower, upper)`: constrain a value to a range (bound order doesn't matter)
* Standard arithmetic: `+`, `-`, `*`, `/`

### Strings

* `LEN(text)`: length of a string, as a number
* `CONTAINS(text, substring)`: whether a string contains a substring (case-sensitive)
* `STARTS_WITH(text, prefix)` / `ENDS_WITH(text, suffix)`: boolean prefix/suffix tests
* `UPPER(text)` / `LOWER(text)`: case conversion
* `TO_TEXT(number)`: convert a number to text (whole numbers drop the trailing `.0`)
* `CONCAT(text, text, ...)`: join two or more strings, in order. Numbers are **not** auto-coerced — wrap them with `TO_TEXT`: `CONCAT("sku-", TO_TEXT(part_id))`

### Lookups

* `LOOKUP(table_name, key1, key2, ...)`: look up a value from a [Lookup Table](/reference/constants-and-lookups#lookup-tables). Supply one key per dimension. Accepts an optional `default:=` keyword argument:

```
LOOKUP(setup_times, product_class, default:=300)
```

Without `default:=`, missing rows return type-specific zero (`0` for numeric tables, `""` for text, `FALSE` for boolean). See [Constants & Lookup Tables](/reference/constants-and-lookups) for the full schema.

### Aggregation Functions

Valid only in **multi-entity contexts** (the seven surfaces listed in [Expression Contexts](#multi-entity-context)). Aggregations operate over the set of entities in scope:

* `SUM(expr)`: sum → number
* `MEAN(expr)`: average → number
* `COUNT(expr)`: count of entities in scope → number (the canonical count idiom is `COUNT(1)`, or `COUNT(1, filter:=...)`)
* `MAX(expr)` / `MIN(expr)`: extremes — **polymorphic**: numeric input gives a number, text input compares lexicographically and gives text
* `MODE(expr)`: most common value — polymorphic; ties break to the first value seen
* `N_UNIQUE(expr)`: count of distinct values → number
* `ANY(expr)` / `ALL(expr)`: boolean input only → boolean

On an empty entity set, `MAX`, `MIN`, `MODE`, and `MEAN` return `0.0`; `SUM`, `COUNT`, and `N_UNIQUE` return `0`; `ANY` returns `FALSE` and `ALL` returns `TRUE` (vacuous truth).

Every aggregation accepts two optional keyword arguments:

* `filter:=<boolean expr>`: only aggregate entities where the filter evaluates true
* `type:=<entity_type_slug>`: only aggregate entities of the named type (unquoted slug)

```
SUM(weight, filter:=qc_passed)
COUNT(1, type:=tube)
MAX(priority, filter:=is_rush)
```

<Note>
  **`type:=` takes an unquoted slug, and hyphenated slugs are not supported in this position.** A type with a hyphen in its slug can't be filtered with `type:=`, so work around it with `filter:=ENTITY_TYPE == "my-type"` instead.
</Note>

**Example: a Combiner producing an assembly, setting output attributes from its inputs:**

```
# Output attribute: total_weight
SUM(weight)

# Output attribute: priority
MAX(priority)

# Output condition: emit only if every input passed QC
ALL(qc_passed)
```

## Type Safety

The DSL is **statically typed**. Every expression is type-checked at model validation time, not at runtime — an expression error surfaces as a validation message with a field path, never as a mid-run stack trace. A condition field expects a boolean; an attribute assignment for a Number attribute must produce a number; a quantity expression must produce an integer. Mixing types fails validation with a specific error pointing at the offending expression: see [Validation](/reference/validation).

What the checker enforces, per operator:

| Construct            | Requirement                                 |
| -------------------- | ------------------------------------------- |
| `+`, `-`, `*`, `/`   | Numbers on both sides                       |
| `AND`, `OR`, `NOT`   | Boolean operands                            |
| `=` / `==`, `!=`     | Both sides the same type                    |
| `<`, `<=`, `>`, `>=` | Numbers on both sides                       |
| `IF(cond, a, b)`     | `cond` boolean; `a` and `b` the same type   |
| `LOOKUP(...)`        | Each key matches its column's declared type |

There is no implicit type coercion. `5 + "x"` doesn't quietly become `"5x"`, it fails validation. Convert explicitly when you need to: `CONCAT("sku-", TO_TEXT(part_id))`.

<Note>
  Type checking happens at validation, but a few arithmetic edge cases are handled at **run** time rather than rejected: a division by zero or an expression that evaluates to `NaN` resolves to `0` and records a run-level advisory instead of aborting the run. Your model keeps going; check the advisories if a computed value looks wrong.
</Note>

## Common Patterns

**Conditional routing**: send each entity down the right path:

```
IF(product_class = "rush", "express_line", "standard_line")
```

**Attribute-driven distribution parameter**: flip the Mean field of a Normal distribution into expression mode so it adapts to each entity, while Standard Deviation stays at a fixed `10`:

```
LOOKUP(cycle_times_by_product, ENTITY_TYPE) * complexity_factor
```

**Backpressure**: slow arrivals when downstream is congested, via an expression on the Source arrival rate:

```
IF(BUFFER_LEVEL("wip-buffer") > 100, 0, 1)
```

**Priority scoring**: sort waiting entities by computed importance:

```
due_priority * 0.7 + customer_tier * 0.3
```

**Assembly output**: combine several inputs into one (Combiner, multi-entity context):

```
# Assembled entity's weight is the sum of its inputs
SUM(weight)

# Emit only if all inputs passed QC
ALL(qc_passed)
```

## Tips and Gotchas

* **Expressions are evaluated on every entity, every time.** Keep them simple where possible: complex expressions in high-throughput paths add up.
* **Component queries return numeric zero.** A missing component name produces `0`, not null. Use the `default:=` keyword arg if you need a different sentinel.
* **LOOKUP returns type-specific zero.** `0`, `""`, or `FALSE` depending on the table's value type. Wrap with `default:=` for a different fallback.
* **Entity attributes are not State Variables.** Attributes belong to an entity and move with it. [State Variables](/reference/events#state-variables) belong to the model and persist across entities.
* **Context matters.** If you hit a validation error like *"identifier `priority` not available here,"* the expression is running in a context that doesn't have an entity in scope. See [Expression Contexts](#expression-contexts).
* **`SELF` vs. bare attribute names.** `SELF` refers to the *component* the expression is attached to (hooks and listeners only). Bare names refer to *entity attributes*. They don't overlap.
* **`ENTITY_TYPE` returns the slug, not the display name.** Compare against `"widget"`, not `"Widget"`.
* **`NOT` binds looser than comparisons.** `NOT qc_passed AND is_rush` is `(NOT qc_passed) AND is_rush`, but `NOT priority > 5` is `NOT (priority > 5)`. Parenthesize anything you'd have to think twice about.
* **Scalar vs. aggregate MIN/MAX.** `MIN_OF(a, b)` compares two numbers anywhere; `MIN(expr)` aggregates over entities and only works in multi-entity contexts.
* **Prefer [Constants and Lookup Tables](/reference/constants-and-lookups)** over hardcoded values. Change a constant in one place; you can't easily find-and-replace across dozens of expressions.
