Skip to content

Template Engine — How It Works ​

How a parametric template becomes drawing primitives: registry → build() → composition → IR → WASM placement. The single shipped template is the Level-1 sewage-pumping-station; the multi-level L0/L1/L2 walkthrough below is a conceptual illustration of the composition engine's capabilities.

This page traces the composition/execution engine end to end so you can analyze it. For authoring syntax see the Template Language Reference; for the conceptual model see the Overview.

Conceptual illustration (not shipped templates): The sections below use an example multi-level set — four Level-0 blocks (submersible-pump-unit, wetwell-level-cluster, inline-flowmeter, discharge-manifold), a Level-1 pump-station-unit that composes them, and a Level-2 pumping-station-area that composes two L1 units — to demonstrate how the composition engine resolves a 2-deep L2→L1→L0 tree. These are illustrative fixtures used in the composition-pipeline tests; the only template shipped in the library is sewage-pumping-station (Level 1).

Pipeline at a glance ​

 author        register            execute (build)               materialise
 ───────       ─────────           ───────────────               ───────────
 Template  →   registry Map   →    BuildContextImpl.build()  →    IR (TemplateOutput)
 .define()     (slug → tmpl)       ├─ place / pipe / route        │
                                   ├─ compose(slug,…)  ◄── recursion (child ctx, same registry)
                                   └─ connect / branch / when / repeat
                                                                  ▼
                                              app/engine/irMapper → place_template(json, ox, oy)  → WASM → CRDT canvas

There are two BuildContext implementations in the tree — important to know:

  • SDK execution context flow/services/template-execution/build-context-impl.ts — the one the registry + composition pipeline use; accumulates a TemplateOutput IR via getOutput(). This is what the composition tests exercise.
  • Canvas/engine context app/engine/templateBuildContext.ts + app/engine/irMapper.ts + app/engine/templateExecutor.ts — maps a TemplateIR to the WASM place_template payload for actual on-canvas placement. They are parallel implementations of the same ctx.* surface; a template's build() runs against whichever context the calling path supplies.

1. Registry — discovery & storage ​

flow/templates/registry.ts holds a module-level Map<string, RegisteredTemplate>.

  • registerAllTemplates() dynamically import()s the ./library/level-0|1|2 barrels in parallel, walks each module's named exports, and registers anything that passes the type-guard _registered === true && typeof slug === 'string' (that flag is set by Template.define).
  • It is on-demand and async — NOT auto-invoked at module load. It must be called once at app bootstrap before any ctx.compose() runs (otherwise compose can't resolve slugs). getTemplate(slug) is the lookup the execution context uses; getRegisteredSlugs() feeds the LLM library index.
  • Tests register templates directly with registerTemplate(tmpl) (and _clearRegistryForTests() between cases), bypassing bootstrap.

2. ctx.compose(slug, params, { at }) — the heart of multi-level ​

In build-context-impl.ts:

  1. Record dependency — pushes slug into _composedSlugs (drives metadata.composedTemplates).
  2. Resolve — registry.getTemplate(slug). If the registry is null or the slug is missing → emits a warning and places a placeholder node @template/<slug> (so a missing block is visible, not silently dropped).
  3. Execute inner — merges supplied params over the inner template's defaults, then spins up a child BuildContextImpl sharing the same registry. That shared registry is what enables nesting — the child can itself compose(). The child runs the inner build().
  4. Copy + offset — every inner node/pipe/label/note/boundary/group is copied into the parent with an id prefix (<slug>_c<n>_) and its position/waypoints offset by at.
  5. Return interfaces — ComposedTemplate.interfaces are the inner template's interfaces(params) with each position offset by at. These offset interface points are what the parent passes to ctx.connect().
  6. Propagate deps upward — the child's _composedSlugs bubble up, so a Level-2 output's metadata.composedTemplates transitively lists the L1 slug and the L0 slugs it pulled in.

⚠ Honest finding — the "max depth 2" rule is not enforced. The spec says nesting is capped at 2 levels, but there is no depth counter or guard in the code. Because the child context shares the registry, an L3→L2→L1→L0 chain would resolve to arbitrary depth without error, and a template that composes itself would infinite-loop / stack-overflow (also unguarded). The shipped templates stay within the 2-deep shape by construction; the engine does not reject deeper or cyclic nesting. (Good candidate for a follow-up: a depth/cycle guard in _executeInnerTemplate.)

3. ctx.connect(from, to) — interface → routed pipe ​

connect() resolves each ref via resolveInterfacePosition():

  • an InterfacePoint → its .position directly,
  • a { symbol, port } → the placed symbol's position plus the port's local offset,
  • a bare string → null (unresolved).

It then creates a pipe connect_<n> routed through exactly two waypoints [fromPos, toPos]. Because composed interface positions are already offset (step 2.5 above), connecting two composed interfaces yields a pipe in the parent coordinate system — e.g. the L2 area wiring duty→storage lands at [40,0]→[80,20].

4. IR → WASM place_template ​

The SDK execution context accumulates private arrays and emits a TemplateOutput IR via getOutput() (node/pipe/label/boundary/group lists + metadata.{composedTemplates,warnings}).

The canvas placement path (separate from the IR-accumulation path) is:

  • app/engine/irMapper.ts → irToPlaceTemplatePayload(ir) converts a TemplateIR (grid units × TEMPLATE_GRID_MM, 1 GU = 8 mm) into a PlaceTemplatePayload,
  • app/engine/templateExecutor.ts → executeTemplate() serializes it and calls the WASM entry engine.place_template(json, origin_x, origin_y), where the engine offsets every element by the drop origin, creates a group, batch-adds elements + connections, and syncs the CRDT canvas.

5. Illustrative counts (from the composition engine tests) ​

The composition-pipeline tests use small inline fixture templates (fixture-pump, fixture-manifold) to verify position offsets, connect waypoints, and dependency propagation — without depending on any shipped library template other than sewage-pumping-station.

To illustrate how node counts compound across levels (conceptually, using the example templates described in the note above): per-block real-node counts would be submersible-pump-unit = 5, wetwell-level-cluster = 5, inline-flowmeter = 4, discharge-manifold = N isolation valves. A composed L1 pump-station-unit at pump_count=3 would total 3×5 + 5 + 3 + 4 = 27 nodes; an L2 pumping-station-area composing two such units (duty=3, storage=2) would total 27 + 21 = 48 nodes with full 2-deep resolution.

The assertions covering these mechanics live in flow/services/template-execution/__tests__/composition-pipeline.test.ts — run it to observe registry resolution, offset propagation, connect waypoints, and dependency tracking.

How to observe it yourself ​

  1. npx vitest run flow/services/template-execution/__tests__/composition-pipeline.test.ts flow/templates/__tests__/level-1-templates.test.ts — verifies registry resolution, compose offsets, connect waypoints, dependency propagation, and the shipped sewage-pumping-station end-to-end execution.
  2. In the app, the Template Stencil (useTemplateStencil) lists the single shipped template (sewage-pumping-station); placing it runs build() through place_template onto the canvas.

Limitations / follow-ups (observed) ​

  • No depth or cycle guard in compose() (see §2) — the depth-2 cap is convention, not enforcement.
  • registerAllTemplates() is not auto-called — composition silently degrades to placeholder nodes if bootstrap didn't register the library.
  • Two parallel BuildContext implementations (SDK vs app/engine) — a divergence risk; they should be reconciled or one delegated to the other.
  • The SDK's authoring-facing compose() is typed to return unknown, so reading .interfaces off a compose result requires a ComposedTemplate cast (see pump-station-unit.ts / pumping-station-area.ts).