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 — 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:- Open the Modeler’s left panel and stay on the Library tab.
- Scroll to the MODELS subsection at the bottom of the panel.
- 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. - Drag the card onto the canvas. A Model Node appears, referencing that sub-model.
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.additionalProperties: false. No other fields are allowed.
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.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 can be shared between a parent and a sub-model by entering an explicitresource_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 can be bridged across the parent/child boundary viatopic_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.
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.
Aliased State Variables
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 viastate_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 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-levelconstants[] / 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_ids, 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 use the qualified form — it’s what you’ll see incomponent_lookup.component_id,process_id,resource_id,variable_name, and thecomponent_idon event/hook rows. Filtering by display name as it appears in the Modeler (say,component = 'Lathe') misses every instance inside a Model Node. Usecomponent = 'cell_a::lathe', or match withLIKE. - 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.
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 runassign / 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 incell_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 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 composes a hierarchy for you, it authors ModelNodes in code with the simulation builder: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.
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:- 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). Filterfrontier_synthetic = falsewhen you only want real hops — excluding the synthetic rows still leaves two rows where a flat model would log one. continued_to_parentlives onentity_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. FilterNOT COALESCE(continued_to_parent, FALSE)when counting real terminations. (Don’t conflate the two datasets — movement crossings are onentity_movements, lifecycle terminations onentity_lifecycle.)entity_idchanges 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=0initial-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 can target a Source or Buffer inside a sub-model with its qualified id (
cell_a::raw_material_intake).

