# Groups — subassemblies with explicit ports

A **group** is a self-contained piece of a bench — a reducer stage, a differential, a sensor cluster — that exposes only a handful of external ports. From the outside it looks like a single component: you wire its ports, you don't care what's inside.

Internally, a group is just a sub-graph. The compiler flattens it by prefixing every child id with the group's id, so the engine, graph builder, and renderer never see a hierarchy. This keeps the kinematic core simple while giving authors a real reuse primitive.

## API

```js
export default defineBench(({ mech, group }) => {
  const stage = group({
    id: 'stage1',
    build: ({ mech: m }) => {
      const a = m.spurGear({ id: 'A', teeth: 20, module: 2 });
      const b = m.spurGear({ id: 'B', teeth: 60, module: 2 });
      m.mesh(a, b);
      // The build function must return a ports map. Keys are the
      // external port names; values are inner port refs. Only ports
      // listed here are reachable from outside the group.
      return { ports: { in: a.ports.axis, out: b.ports.axis } };
    },
  });

  const driver = mech.spurGear({
    id: 'D',
    teeth: 20,
    module: 2,
    transform: { position: [0, 0, 0] },
  });
  mech.mesh(driver, stage.ports.in);
  mech.drive(driver, { rpm: 60 });
});
```

`group({ id, build })` does three things:

1. Runs `build` against a **fresh recorder**. The inner DSL context (`mech`, `elec`, `group`, `defineComponent`, `deg`, `rad`) is the same shape as the outer one, so the inner code does not know it is inside a group.
2. Returns a handle that behaves like a component: `stage.id`, `stage.ports.<name>`. You pass it to connections exactly like `mech.mesh(driver, stage.ports.in)`.
3. Inlines the inner components, connections, and drivers into the outer document, prefixing every inner id with `${groupId}/`. In the example above, the compiled document contains `stage1/A` and `stage1/B` — not a nested "stage1" node.

## Namespacing rules

- **Id collisions** inside a group are caught by the inner recorder and reported with the group's local id (`A`, `B`), not the prefixed form — you read the error the same way you'd read it in a flat bench.
- **Outer ids** must still be unique: the outer recorder sees prefixed ids (`stage1/A`), so a second group with the same `id` errors loudly.
- **External ports** expose only what you list in the build's return value. An inner port that isn't in `ports` is unreachable from the outside — connections referencing it from outside the group fail at compile time.
- **Drivers inside a group** are allowed but uncommon: a group with an internal driver simulates standalone. Most groups stay passive and rely on the outer bench for input.

## Local kinds inside a group

You can register `defineComponent(...)` inside a group's build. The kind is scoped to the **innermost compile** and visible in that group's build context only — it does not leak to the outer bench, to sibling groups, or to future compiles of the same source. This is the same ergonomics as `defineComponent` at the top level, just narrower.

## Multi-file benches

Inline `build` is the default, but a bench can also split its groups across multiple source files. Each file ends with `export default defineBench(...)` and declares its exposed ports the same way an inline build does. From the entry source you reference another file by name:

```js
// stageA
export default defineBench(({ mech }) => {
  const g = mech.spurGear({ id: 'gear', teeth: 20, module: 2, width: 10, boreDiameter: 8 });
  return { ports: { out: g.ports.axis } };
});

// main
export default defineBench(({ group, mech }) => {
  const stage1 = group({ id: 'stage1', source: 'stageA' });
  const driver = mech.spurGear({ id: 'D', teeth: 20, module: 2, width: 10, boreDiameter: 8 });
  mech.mesh(driver, stage1.ports.out);
  mech.drive(driver, { rpm: 30 });
});
```

The compiler treats `source: 'name'` exactly like an inline `build` — it resolves the named file, runs its build against a fresh recorder, and inlines the results with the `stage1/` prefix. Id prefixing, local-kind scoping, and the ports contract are identical.

`source` and `build` are mutually exclusive. Outside the multi-source model (a bench stored as a single `source` string), `group({ source })` errors loudly — only `benches.sources[]` enables the resolver.

In the Workspace, each file shows up as its own tab; the tab marked `entry` is the one the compiler starts from.

## Declarative external ports

A reusable sub-bench can publish its external interface **statically** via the object form of `defineBench`:

```js
// stageA
export default defineBench({
  build: ({ mech }) => {
    mech.spurGear({ id: 'gear', teeth: 20, module: 2, width: 10, boreDiameter: 8 });
  },
  externalPorts: [
    { id: 'out', internal: 'gear.axis' },
  ],
});
```

When a parent's `group({ source })` resolves a sub-bench that declares `externalPorts`, the declared list **wins** — the build's returned `{ ports }` is ignored. Each entry has `{ id, internal: '<componentId>.<portId>', type? }`. The compiler validates that every `internal` reference points at an actual inner component and fails loudly otherwise.

Why bother when the legacy "build returns ports" form works? Because the declarative form makes the contract readable without running the body — useful for documentation, type generation, and future tooling that wants to inspect a bench's I/O without compiling it. Inline groups (`group({ build })`) have nowhere to declare it and continue using the legacy form.

Both forms of `defineBench` keep working everywhere a bench is the *entry* source — declared `externalPorts` are simply ignored when there is no parent group to consume them.

## The group node in the document

Alongside the flattened children, the compiler emits one component per group with `kindId: 'group'` and the group's authored id. The node is **kinematically inert** — its kind has `ports: []`, so the graph builder creates no nodes for it and propagation runs only over the inner children. What the node carries is metadata:

- `externalPorts: [{ id, componentId, portId, type }]` — the same data `stage.ports` exposes at authoring time, persisted so renderers, inspectors, and layout solvers can address the group's I/O without re-running the compile.
- `children: [...]` — the prefixed ids of the inner components that belong to this group, materialized so consumers do not have to parse id segments.

The TreePanel and the Inspector look for these explicit nodes first and fall back to id-prefix detection when a document predates this format. The DSL palette, the AI system prompt, and the auto-generated /docs catalog continue to hide the group kind because authors do not pick it from a list — `api.group(...)` stays the only authoring entry point.

## Namespace convention

Inner component ids carry the group's authored id as a prefix joined by `/`: a child `A` inside `stage1` becomes `stage1/A` in the compiled document; a deeper child `B` inside a nested group `sub` of `stage1` becomes `stage1/sub/B`. The compiler enforces three invariants on this convention via the **expandGroups** pass that runs right after validation and before the layout solver:

- `E_GROUP_PREFIX_ORPHAN` — a component whose id implies a group prefix that does not match any `kindId: 'group'` node.
- `E_GROUP_CHILDREN_MISMATCH` — a group node whose declared `children[]` drifted from the prefixed components actually present (for example, a GUI patch removed a child without updating the group's metadata).
- `E_GROUP_EXTERNAL_PORT_DANGLING` — a declared external port targeting a component or port id that does not exist after inlining.

When the DSL is the only authoring path, none of these errors fire. They are guard rails for any future code path that mutates the document directly (the GUI param-edit pipeline already syncs back to the source via AST surgery, but downstream tools that bypass the source could otherwise produce silently inconsistent documents).

## What groups are not

- **Not a new engine concept.** The graph builder, tick loop, and viewer see a flat bench after compilation; the explicit `group` node is metadata, not a propagation target.
- **Not addressable as a connection endpoint.** External connections (`mech.mesh(driver, stage.ports.in)`) are rewritten at compile time to the prefixed inner port (`mech.mesh(driver, 'stage1/A.axis')`); no edge in the document references the group node's port directly.

## When to reach for a group

- You are copy-pasting the same 3-5 components and their connections in more than one bench.
- A subassembly has a **clear I/O contract** — usually one input shaft and one output shaft for mechanical, or a pair of power/signal terminals for electronics.
- You want to tune parameters of the subassembly (number of stages, tooth counts) from the outside without rewriting the internals.

When the I/O contract isn't clear or the components really are one-offs, inline them directly — groups add a layer of indirection, and indirection without reuse is just noise.

## Roadmap

- **v1 (shipped):** inline `group({ id, build })`.
- **v2 (shipped):** split a group into a separate file via `group({ id, source })` against a multi-source bench. The ABI (ports map as return value, id prefixing, local kind scoping) is identical to inline mode.
- **v3:** a curated hub of shared groups, versioned like kinds (`apiVersion`).

See plan §17 for the full design trade-offs.
