> ## 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.

# Constants & Lookup Tables

> Named values and multi-dimensional tables that externalize model parameters from logic.

## Overview

Constants and Lookup Tables are how you separate a model's **parameters** from its **logic**. Instead of hardcoding values inside expressions, you name them, store them in one place, and reference them from anywhere. Change the named value once, and every expression that references it updates automatically.

This matters for three reasons:

1. **Tuning**: adjust model parameters without hunting through expressions. Change `BASE_PROCESSING_TIME` from 60 to 75 and every relevant component follows.
2. **Readability**: `SHIFT_EFFICIENCY_FACTOR` is easier to understand than `0.87` sprinkled across expressions.
3. **Non-technical access**: operators and analysts who shouldn't modify [expressions](/reference/expressions) can still tune the model by changing constant values.

Both Constants and Lookup Tables are **factory-scoped**: they live at the factory level and can be reused across models. But each model has to **opt in** by listing the constant or lookup table slug in the top-level `constants` / `lookup_tables` arrays of its `model.json`. A constant that exists in the factory but isn't in the model's opt-in list is invisible to that model.

<Warning>
  **Referencing a constant or table that isn't opted in fails validation.** The expression checker resolves names against the model's opt-in lists, so a `LOOKUP` against a table the factory has but the model hasn't listed produces a validation error — that's the symptom you'll actually see when the opt-in step was skipped.
</Warning>

## Constants

A **Constant** is a named value with a specific type: `number`, `text`, or `boolean`. Each constant has:

* **Name**: how expressions reference it. Names use SCREAMING\_SNAKE\_CASE (e.g., `BASE_CYCLE_TIME`)
* **Description**: what it represents and what units
* **Type**: `number`, `text`, or `boolean`
* **Value**: the actual data

<Warning>
  **The type is `text`, not `string`.** The schema enum is `["boolean", "text", "number"]`. Writing `"type": "string"` in a constant definition fails validation. The UI Type dropdown labels the option *Text*.
</Warning>

<Note>
  **Names auto-uppercase as you type.** The constant-name input transforms input client-side to SCREAMING\_SNAKE\_CASE: typing `lowercase_name` saves as `LOWERCASE_NAME`. Underscores you type are preserved; spaces are not added for you. Reference the name as it ends up after the transform.
</Note>

**Expressions reference the name, not the slug.** The name must therefore be a valid DSL identifier, and it can't collide with anything else the expression checker resolves by name: reserved DSL functions, entity type slugs, component ids, state variable names, or other constant and lookup table names. Name collisions fail validation with an error naming both ends of the conflict.

Reference a constant by its name in any expression:

```
BASE_CYCLE_TIME * SHIFT_EFFICIENCY_FACTOR
```

### Constants vs. State Variables

Constants are **static for the duration of a run**: they cannot be reassigned. To mutate a value during simulation, use a [State Variable](/reference/events#state-variables) instead. State variables expose a runtime `assign` action that constants don't.

The scope differs too: constants are **factory-scoped** and shared across every model that opts in, while state variables are **model-scoped**. A constant is the right tool for a parameter you tune between runs or share between models. A state variable is the right tool for a value the simulation itself changes: current shift, rolling WIP counter, demand signal.

### When to Use a Constant

* **Any value that appears in multiple places.** If you find yourself typing `0.87` in several expressions, extract it to a constant.
* **Any value you might want to change between runs.** Edit the constant, capture a new [Snapshot](/reference/snapshots), and the snapshot freezes that value for downstream comparison.
* **Any value a non-technical stakeholder might need to adjust.** Analysts can tune a constant without knowing the expression language.

<Note>
  **Experiments don't auto-sweep constants.** An Experiment compares **Snapshots**, not parameter values directly. To sweep a constant across an experiment, change its value, capture a snapshot, change it again, capture another snapshot, then add both snapshots to the experiment.
</Note>

### Managing Constants

Constants live in the **Lookups** modal in the Modeler's Library panel: click the **Lookups** button at the bottom of the Library and open the **Constants** tab. The same modal also hosts **Lookup Tables**, **State Variables**, and **Topics**, each on its own tab. Add, edit, or delete constants there, and changes propagate to every model in the factory that has the constant in its opt-in list.

Each constant is stored at `factory/constants/{slug}.json`.

## Lookup Tables

A **Lookup Table** is a named, typed-key table queryable in expressions. Each table has a value type (`number`, `text`, or `boolean`) and one or more **keys** (called "Attributes" in the UI — these are the table's key columns, unrelated to entity [attributes](/reference/entities)). Keys are themselves **typed** (each can be `boolean`, `text`, or `number`), so a multi-dimensional lookup can mix key types.

Query a table with `LOOKUP`:

```
LOOKUP(cycle_times, ENTITY_TYPE)
```

### Providing a Fallback Value

`LOOKUP` accepts an optional `default:=` keyword argument that returns the supplied value when no row matches:

```
LOOKUP(yield_rates, ENTITY_TYPE, default:=0.95)
```

This is the canonical way to give a lookup a fallback. **Always prefer `default:=` over wrapping in `IF()`**: it's a single expression with no double-evaluation of the same lookup.

<Note>
  **Without `default:=`, missing rows return type-specific zero**: `0` for a numeric table, `""` (empty string) for a text table, `FALSE` for a boolean table. That zero is silent and easily mistaken for a real result. Use `default:=` whenever the difference between "no row matched" and "value is exactly zero" matters.
</Note>

The keyword-argument syntax uses `:=`, not `=` (which means equality in the DSL). Despite the visual resemblance to Python's walrus operator, `:=` here is simply a named-argument marker, not an assignment. The same `:=` marks the `filter:=` and `type:=` keyword args on [aggregation functions](/reference/expressions#aggregation-functions) and `ndigits:=` on `ROUND`.

### Multi-Dimensional Lookups

A Lookup Table can be configured with more than one key column, so a single table can capture data that varies across two or more axes at once, like "processing time by (product type, station)" or "yield rate by (shift, material class)." Query it with one positional argument per key column, in the order the table defines them:

```
LOOKUP(cycle_times_by_station, ENTITY_TYPE, station_name)
LOOKUP(cycle_times_by_station, ENTITY_TYPE, station_name, default:=180)
```

Each key column has its own type and a **required** `possible_keys` allowlist — the enumerated set of values that column accepts. `possible_keys` is load-bearing, not decoration: the platform validates every table entry against it at build time, and the UI's key dropdowns are populated from it. Design a lookup table by enumerating each key's domain up front.

Multi-key `LOOKUP` calls match rows where all key columns equal the values you pass in.

**One argument per key column.** There's no wildcard or partial-match syntax: always pass exactly as many keys as the table defines.

**Composite keys via `CONCAT`.** When a natural key is a compound string, build it in the expression rather than adding a column per fragment:

```
LOOKUP(machine_rates, CONCAT("station-", TO_TEXT(station_id)))
```

### Managing Lookup Tables

Lookup Tables are managed in the same **Lookups** modal as Constants: open it from the Library panel and switch to the **Lookup Tables** tab. Each table is stored at `factory/lookup_tables/{slug}.json` with:

* `type` — the value type (`number`, `text`, or `boolean`)
* `key_definitions` — the typed key columns (the UI labels these *Attributes*), each carrying a `name`, a `type`, and its `possible_keys` allowlist
* `entries` — the rows, each `{"keys": [...], "value": ...}` with one key per column in definition order
* `dimension` — the key-column count, derived from `key_definitions`

Entries are validated when the table is built: a `keys` array that doesn't match the column count, a key outside its column's `possible_keys`, or a `value` that doesn't match the declared `type` is rejected before the table is accepted — you won't discover a malformed row at simulation time.

### When to Use a Lookup Table

* **Values that vary by a categorical attribute.** Processing time by product type, yield rate by material, priority weight by customer tier, all natural lookups.
* **Routing and proportional splits.** Different entities route differently; store the routing rules in a table rather than deeply nested `IF` expressions.
* **Data-driven configuration.** When the "configuration" of a model is really a set of numbers tied to categories, a lookup table is clearer than encoding the same information across many fields.

## Constants vs. Lookup Tables: Which to Use?

* **Single value used in many places** → Constant
* **Value that depends on a categorical attribute** → Lookup Table
* **Needs to be swept across snapshots in an experiment** → Constant (one value per snapshot)
* **Large reference dataset** → Lookup Table

It's common to use both: a constant for a global factor, a lookup for per-type rates, combined in one expression.

## Patterns

**Processing time by product:**

```
LOOKUP(cycle_times, ENTITY_TYPE) * BASE_PROCESSING_FACTOR
```

**Conditional cost or surcharge with safe fallback:**

```
IF(priority == "rush",
   LOOKUP(rush_surcharges, ENTITY_TYPE, default:=0),
   LOOKUP(standard_rates, ENTITY_TYPE, default:=0))
```

**Tunable shift dynamics:**

```
IF(SIM_TIME < SHIFT_1_END,
   BASE_RATE * SHIFT_1_EFFICIENCY,
   BASE_RATE * SHIFT_2_EFFICIENCY)
```

## Tips

* **DSL arithmetic is type-strict.** Numbers add to numbers; text doesn't quietly coerce. Catch type mismatches at validation rather than relying on silent conversions.
* **The value type must match the context that reads it.** A `boolean` constant in a numeric expression, or a `text` lookup value feeding a numeric slot, fails validation with a type error — a common trip point after refactoring a table from number to text values.
* **Use `default:=` defensively.** If a missing lookup row should be anything other than the type's zero, `default:=` is shorter and clearer than an `IF()` wrapper around two `LOOKUP` calls.
* **Each model opts in.** Adding a constant or lookup at the factory level is the first step. The model's top-level `constants` / `lookup_tables` arrays decide whether that model sees it.
