Hand a shared port type on through a chain of nodes (#210)
game-maker / gate (push) Successful in 2m51s
game-maker / suite (push) Successful in 14m44s

Since #207 the output of First not empty or Select takes the type
its inputs agree on. A second node of that kind behind the first
stayed any, because the first one answered with the any it was
registered with. That was the guard against a circle of such nodes
asking each other forever. The issue asks for the type to travel
through the chain. Answered there: measure with 50 nodes and build
no cache, and the painted input pins follow along.

- shapedDef in core/graph/nested.ts takes a Trail in place of the
  flag inherit. Its set open holds the nodes still being asked. A
  node reached a second time reports the any it was registered
  with, which ends a circle.
- The map done of the Trail keeps each answer of one question. A
  node feeding both inputs of the next is asked once, so the work
  does not double with every link of a chain.
- ownShape is the part of shapedDef that builds the ports before a
  shared group has its say. It is split off, nothing in it changed.
- Inputs stay any and only outputs inherit, as in #207. An edge
  that turns invalid further down is left in place and drawn in the
  danger colour.
- paintedInputs uses one Trail for all pins of a node, so the pins
  show the type handed on through the chain.
- Nothing is cached between two changes. A chain of 50 resolved in
  about 10 ms where it took 2 ms before, measured earlier in this
  round and not again for this commit.

Tests:
- Gate on melektaus-atx: 211 files, 3240 tests (3227 + 13), exit 0
- e2e 1-6 on melektaus-atx: 298 checks, all pass
- e2e 4,6,18,19,20,23,24,26,28,31,36,43,53: 558 checks, all pass
- No full run and no smokes. The sum of 1673 (1671 + 2) is reckoned,
  1671 is what CI counted for c88f57c.
- Fourteen unit tests in shared-port-type.test.ts: a chain of two
  and of three, mixed kinds, a mixed and an open input, undo at the
  front, a circle of two, a node feeding itself, behind a circle, an
  invalid edge further down, the chain of 40 asked once per node,
  the chain of 50, and two for the pins. The test saying the type
  does not pass through a second node is gone, hence 13 more.
- Two e2e checks in module 06: the second node takes number, and a
  text at the front opens the whole chain again.
- Four unit counter-probes and one browser counter-probe all fail
  their tests. With the old behaviour only the chain check fails.
- Not checked in the browser: the circle and the chain of three,
  which have unit tests only. Nothing was looked at after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 14:41:30 +02:00
1 parent c88f57cf0a
commit 9ca220a285
4 files changed
+268 -39

No files matched your search

+39 -22
View File
@@ -145,7 +145,7 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
*/
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
const def = shapedDef(node, graph, lookup, true);
const def = shapedDef(node, graph, lookup, newTrail());
return def && splitDef(def, node);
}
@@ -158,24 +158,41 @@ function splitDef(def: NodeDef, node: GraphNode): NodeDef {
* produces its wholes as always; reading a field port picks the field out of one (`splitField`).
*/
export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
return shapedDef(node, graph, lookup, true);
return shapedDef(node, graph, lookup, newTrail());
}
/**
* `inherit` is off while answering what feeds a shared group: a node of that kind then reports the
* `any` it was registered with, so two of them in a row cannot ask each other in circles (#207).
* One question about a shared group, followed through the nodes of that kind in front of it (#210).
* `open` holds the nodes still being asked: reaching one again is a data cycle, and it then reports
* the `any` it was registered with, which ends the circle. `done` keeps each answer, so a node
* feeding both inputs of the next is asked once and not once per path.
*/
function shapedDef(
node: GraphNode,
graph: Graph,
lookup: GraphLookup,
inherit: boolean,
): NodeDef | undefined {
interface Trail {
open: Set<string>;
done: Map<string, NodeDef>;
}
function newTrail(): Trail {
return { open: new Set(), done: new Map() };
}
function shapedDef(node: GraphNode, graph: Graph, lookup: GraphLookup, trail: Trail): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def) return undefined;
const shared = inherit ? def.sharedType : undefined;
if (!def.declaresPorts && !def.derivePorts)
return shared ? withSharedType(def, shared, node, graph, lookup) : def;
const shared = def.sharedType;
if (!shared || trail.open.has(node.id)) return ownShape(def, node, graph, lookup);
const known = trail.done.get(node.id);
if (known) return known;
trail.open.add(node.id);
const typed = withSharedType(ownShape(def, node, graph, lookup), shared, node, graph, lookup, trail);
trail.open.delete(node.id);
trail.done.set(node.id, typed);
return typed;
}
/** The ports of this node before any shared group has its say. */
function ownShape(def: NodeDef, node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef {
if (!def.declaresPorts && !def.derivePorts) return def;
const own = (side: 'inputs' | 'outputs') =>
declaredOn(def, node.ports ?? [], side).map((p) =>
def.declaredPort ? def.declaredPort(p, node, graph, lookup) : portDefOf(p),
@@ -185,13 +202,12 @@ function shapedDef(
const ports = [...def[key], ...(grown[key] ?? []), ...own(key)];
return grown.inOrder ? ports : execFirst(ports);
};
const resolved = {
return {
...def,
label: grown.label ?? def.label,
inputs: side('inputs'),
outputs: side('outputs'),
};
return shared ? withSharedType(resolved, shared, node, graph, lookup) : resolved;
}
/** The group's outputs, retyped to what its inputs agree on; `any` leaves the definition alone. */
@@ -201,8 +217,9 @@ function withSharedType(
node: GraphNode,
graph: Graph,
lookup: GraphLookup,
trail: Trail,
): NodeDef {
const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, false)));
const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, trail)));
if (type === 'any') return def;
return {
...def,
@@ -211,21 +228,20 @@ function withSharedType(
}
/**
* What the edge into that input carries, or undefined while nothing is connected to it. `inherit`
* says whether a shared group on the *feeding* node may have its say: off while a group asks what
* feeds it, on when only the drawn colour is wanted, which is what the edge itself is drawn in.
* What the edge into that input carries, or undefined while nothing is connected to it: the type
* the feeding node resolves to, which is what the edge itself is drawn in.
*/
function feedingType(
node: GraphNode,
key: string,
graph: Graph,
lookup: GraphLookup,
inherit: boolean,
trail: Trail,
): PortType | undefined {
const edge = edgesInto(graph, node.id, key)[0];
if (!edge) return undefined;
const from = findNode(graph, edge.from.node);
const def = from && shapedDef(from, graph, lookup, inherit);
const def = from && shapedDef(from, graph, lookup, trail);
return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type;
}
@@ -236,9 +252,10 @@ function feedingType(
*/
function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null {
if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null;
const trail = newTrail();
return def.inputs.map((port) => {
if (port.type !== 'any' || port.ghost) return port;
const fed = feedingType(node, port.key, graph, lookup, true);
const fed = feedingType(node, port.key, graph, lookup, trail);
return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed };
});
}