Skip to main content

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

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 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 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.
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 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 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_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 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 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.
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.
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). 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 can target a Source or Buffer inside a sub-model with its qualified id (cell_a::raw_material_intake).