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

# Model Nodes & Hierarchical Modeling

> Embed one model inside another to build reusable sub-models and scale to facility-level simulations.

## Overview

A **Model Node** is a component that embeds another simulation model inside the current one. Instead of duplicating components across many models, you define a sub-model once (a production cell, an assembly line, a department) and embed it wherever you need it. Changes to the sub-model propagate everywhere it's used.

This is the architecture for complex facilities: model a single cell rigorously, compose a department from copies of that cell, compose a facility from copies of the department. Without hierarchical modeling, a complex operation means thousands of components on a single canvas. With it, you get a clean decomposition that mirrors how your operation is actually organized.

If you're new to building models, start with the [Simulation overview](/product/simulation-overview) — this page covers the composition layer that sits on top of the component basics.

## When to Use Model Nodes

* **Repeated physical structure**: your facility has five identical production cells. Model one, then embed it five times via Model Nodes.
* **Reusable templates**: standard sub-processes (QC inspection, packaging, kitting) that appear across many models. Define once, then reuse across every model in the factory that imports it.
* **Scale**: once a model passes \~50-100 components, hierarchical decomposition becomes the only way to keep it navigable.
* **Team division**: one person owns the line-level sub-model, another owns the facility-level composition. Model Nodes let them work in parallel.

## How a Model Node Is Added

A Model Node is a reference to a separate model that already exists in the same factory. To add one to the current model:

1. Open the Modeler's left panel and stay on the **Library** tab.
2. Scroll to the **MODELS** subsection at the bottom of the panel.
3. Click **+ Import Model** to open a picker listing the factory's other models. Selecting one adds the model's slug to the parent's `imported_models[]` list and surfaces it as a draggable card in the MODELS subsection.
4. Drag the card onto the canvas. A Model Node appears, referencing that sub-model.

<Warning>
  **The parent must list the sub-model in `imported_models[]` before a Model Node referencing it can validate.** That list is the gate: without it, the ModelNode's `nested_model_id` doesn't resolve and the model fails validation. *+ Import Model* manages this list automatically when you use it, but if you author the JSON by hand you must add the slug yourself.
</Warning>

## Schema Fields

A ModelNode is a thin reference object: the bulk of what it does lives in the sub-model. Required fields are sparse; everything else is optional and defaults to empty.

| Field                     | Required | Purpose                                                                                                           |
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `id`                      | yes      | Component id (used in qualified paths, schedule `element_id`, dataset columns)                                    |
| `nested_model_id`         | yes      | Slug of the sub-model. Must appear in the parent's `imported_models[]`.                                           |
| `name`                    | no       | Display name (defaults to id)                                                                                     |
| `description`             | no       | Free text                                                                                                         |
| `component_type`          | no       | Const `"model_node"` (auto-defaulted)                                                                             |
| `input_mappings`          | no       | List of `{external_connection_id, internal_component_id}` pairs: wires parent connections to sub-model components |
| `output_mappings`         | no       | Same shape: wires sub-model components to parent connections                                                      |
| `resource_mappings`       | no       | Per-resource alias map; unmapped resources stay local                                                             |
| `topic_mappings`          | no       | Per-topic alias map; bridges parent ↔ sub-model bidirectionally                                                   |
| `state_variable_mappings` | no       | Per-state-variable alias map; bridges read/write bidirectionally                                                  |
| `event_listeners`         | no       | Topic-driven listeners on the ModelNode itself                                                                    |
| `event_hooks`             | no       | Schema accepts the field, but no canonical lifecycle events are documented for ModelNodes (see below)             |

`additionalProperties: false`. No other fields are allowed.

<Note>
  The `event_hooks[]` and `event_listeners[]` arrays aren't free-form: they use the same `EventHook` / `EventListener` definitions as every other component, so each hook entry requires `event` + `actions` and each listener entry requires `topic`.
</Note>

## Mapped Connections

`input_mappings` and `output_mappings` are the wiring layer: each entry pairs an `external_connection_id` (a connection on the parent canvas) with an `internal_component_id` (a component inside the sub-model). When the parent's flow reaches an input mapping, entities flow into the named sub-model component; when an output mapping fires, entities cross back out.

You can change the sub-model's internal structure without breaking the parent: as long as the mappings still resolve to valid components inside, the parent model keeps working.

## Shared Resources

A [Resource](/product/simulation-modeling#component-types) can be shared between a parent and a sub-model by entering an explicit `resource_mappings` entry. Each entry carries an `internal_resource_id` (the resource the sub-model asks for) and an `external_resource_id` (the parent resource it should draw from instead) — the alias points **from the sub-model's internal name outward to a parent resource**. **Unmapped resources stay local to the sub-model**: if you don't map it, the sub-model uses its own resource of that name.

The useful mental model is **internal → outer, per instance**: each mapping entry replaces one of the sub-model's internal resources with a resource from the parent. Because the binding lives on the ModelNode instance (not on the sub-model), the same sub-model instantiated five times can bind each instance to a *different* parent resource — five cells sharing one maintenance crew, or each drawing from its own operator pool.

Use shared resources for operators or equipment that physically serve multiple cells.

## Bridged Topics

[Topics](/reference/events#topics) can be bridged across the parent/child boundary via `topic_mappings`. Bridging is **always bidirectional**: there's no per-direction configuration. An event emitted inside the sub-model on a bridged topic is received in the parent, and vice versa.

<Info>
  In results data, a bridged emission appears as **two rows** in the topic-emission dataset — the originating emit plus the bridged delivery on the other side of the boundary. Don't double-count when aggregating.
</Info>

## Aliased State Variables

[State Variables](/reference/events#state-variables) can be aliased: a state variable declared at the parent level can be referenced from inside the sub-model under a different name via `state_variable_mappings`. The aliasing is **bidirectional read/write**: both sides see and write the same underlying value. An aliased nested variable *delegates* to the parent — in results datasets it emits no initial-value row of its own at `t=0`; the parent variable is the single source of truth.

This keeps sub-models decoupled (they reference their own internal names) while still letting them participate in parent-level state.

## Constants and Lookup Tables Don't Need Mapping

[Constants](/reference/constants-and-lookups) and lookup tables are **factory-scoped**. Both the parent and sub-model reference them by slug independently: there's no aliasing layer for these on a ModelNode. Each model opts in via its own top-level `constants[]` / `lookup_tables[]` lists — these sit directly on the model definition, alongside `sources`, `processes`, `model_nodes`, `imported_models`, `state_variables`, and `topics` — so parent and sub-model can opt into the same factory-level constant without coordinating.

## Qualified Component Paths

Components inside a Model Node are addressed by a **qualified path** using `::` as the separator. A component with id `lathe` inside a ModelNode with id `cell_a` is referenced as `cell_a::lathe` anywhere outside its parent sub-model (the same form you'll see in chart View source SQL, schedule `element_id`s, and dataset columns).

All segments are component **slugs**, not display names. The `::` separator is reserved, so no single component id may contain it.

Component ids are namespaced **per model**: the same id can legitimately exist in both the parent and a sub-model (`lathe` at the top level and `cell_a::lathe` inside a cell are different components). The `::` qualification is what disambiguates them.

This applies in three places:

* **Chart View source queries.** Component identifiers in `entity_movements`, `process_activity`, `resource_activity`, and other [dataframes](/product/results-and-analytics#querying-simulation-data) use the qualified form — it's what you'll see in `component_lookup.component_id`, `process_id`, `resource_id`, `variable_name`, and the `component_id` on event/hook rows. Filtering by display name as it appears in the Modeler (say, `component = 'Lathe'`) misses every instance inside a Model Node. Use `component = 'cell_a::lathe'`, or match with `LIKE`.
* **Schedule material releases.** A scheduled material release to a Source inside a sub-model must name the Source with its qualified id: `cell_a::raw_material_intake`.
* **Cross-boundary references in DSL component-query functions**: pass the qualified id when the component you're querying lives in a sub-model.

Authoring hooks and listeners *inside* the sub-model uses **local** component ids: the qualified prefix is only needed when crossing the parent-child boundary. Sub-models stay reusable; they don't need to know what name their parent gave them.

For deeply nested structures, paths chain: `plant_1::line_3::cell_2::lathe`.

## ModelNode Event System Support

ModelNodes can carry **Event Listeners** (topic-driven) and they work fully: listen for any topic in the parent or bridged into the sub-model, and run `assign` / `emit` / etc. like any other listener. Useful for ModelNode-level coordination that isn't tied to a single internal component.

ModelNodes also accept an `event_hooks[]` array in the schema, but **no canonical lifecycle events are documented for ModelNodes** — there's no published event a hook here could subscribe to. Stick to listeners for ModelNode-level reactions.

## Validation Recurses Across Boundaries

When you validate the parent model, validation recurses into every nested model referenced by a ModelNode. Errors inside a sub-model bubble up to the parent **prefixed with the model-node path**, so a broken expression in `cell_a`'s lathe surfaces on the parent as an error attributed to the nested model rather than silently passing.

Fix the error in the sub-model; the parent re-validates automatically.

## Live Reference, Not Snapshot

A ModelNode references its sub-model by slug. Any change you make to the sub-model file is reflected immediately in every ModelNode that references it: there's no snapshot pinning at the ModelNode level. If you need to freeze a specific revision of a sub-model, capture a [Snapshot](/reference/snapshots) of the parent — a snapshot embeds the entire recursive model registry (entities, constants, lookup tables, **and sub-models**) at that moment, and snapshots are immutable once captured.

## Authoring Model Nodes with Dexter

The visual editor isn't the only entry point. When [Dexter](/product/ai-assistant) composes a hierarchy for you, it authors ModelNodes in code with the simulation builder:

```python theme={null}
simulation.model_node(
    "Cell A",
    nested_model=cell_template,
    inputs={"raw_in": "raw_material_intake"},
    outputs={"finished_out": "cell_sink"},
    resource_mappings={"operators": "shared_operator_pool"},
    topic_mappings={"replenish": "replenish"},
)
```

`nested_model` takes a **saved** model — its slug string, or a `model()` handle from `simulation.model(...)`. A fresh in-memory model that hasn't been saved won't resolve.

<Warning>
  The builder currently exposes `resource_mappings` and `topic_mappings`, but **not** `state_variable_mappings`, `event_hooks`, or `event_listeners` — even though the schema accepts all of them. If you need those fields on a ModelNode today, ask Dexter to apply them as a raw model edit rather than through the builder call.
</Warning>

Dexter can also work on one subsystem at a time — slicing out a sub-model at any level of the hierarchy, changing it, and applying it back — so a facility-scale model stays editable piece by piece.

## Nested Models in Results Data

Hierarchy is flattened at simulation time, but the boundary crossings remain visible in the [results dataframes](/product/results-and-analytics#querying-simulation-data):

* **Every boundary traversal logs four rows in `entity_movements`**: parent → nested source (synthetic), nested source → first internal component (real), last internal component → nested sink (real), and nested sink → parent (synthetic). Filter `frontier_synthetic = false` when you only want real hops — excluding the synthetic rows still leaves two rows where a flat model would log one.
* **`continued_to_parent`** lives on `entity_lifecycle`: on terminated rows at a nested output sink it distinguishes a real termination inside the sub-model from an entity crossing back out to the parent. Filter `NOT COALESCE(continued_to_parent, FALSE)` when counting real terminations. (Don't conflate the two datasets — movement crossings are on `entity_movements`, lifecycle terminations on `entity_lifecycle`.)
* **`entity_id` changes at every Transformer, Combiner, and Separator.** If a sub-model wraps a transformation, the entity that leaves is not the entity that entered — carry a stable business-identity attribute (an order number, a serial) if you need to trace a unit across the boundary.
* **Aliased state variables emit no `t=0` initial-value row** (they delegate to the parent variable), and **bridged topics log two rows per emission** (origin + bridged delivery).

## Common Patterns

**Cell template.** A "production cell" sub-model is created once with canonical equipment. The facility model instantiates it five times via Model Nodes, each pointing at the same sub-model slug.

**Department composition.** Build a "fabrication" model and an "assembly" model separately, then compose them in a facility model with connections between their outputs and inputs.

**Progressive detail.** Start with a coarse facility model, a few ModelNodes, each representing a department as a black box. As you need more fidelity, replace a ModelNode's sub-model reference with a more detailed one. The parent doesn't change.

## Tips

* **Treat sub-models as libraries.** Once a sub-model is stable, avoid editing it casually: changes propagate to every instance.
* **Name inputs and outputs clearly.** Mapping ids match by name; clear names prevent wiring mistakes.
* **Keep the top level thin.** A well-organized facility model is mostly Model Nodes with a little glue between them. If your top level has hundreds of direct components, you're losing the benefit.
* **All path segments are slugs.** When in doubt about an id, check the schema or the dataset columns: display names with spaces don't appear in `::` paths.
* **Nesting doesn't hide provenance.** Assumption stamps (`derived` / `stated` / `assumed`) apply inside sub-models exactly as they do at the top level, and an assumptions review walks the full recursive registry — a guess buried three levels deep still surfaces.
* **Schedules reach inside.** A [material release](/reference/schedules#material-releases) can target a Source or Buffer inside a sub-model with its qualified id (`cell_a::raw_material_intake`).
