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

+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1671 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1673 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+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 };
});
}
+193 -16
View File
@@ -100,21 +100,6 @@ describe('First not empty, whose a, b and value carry the same value', () => {
expect(may).toBe(true);
});
it('does not pass the type on through a second one of its kind', () => {
const f = fixture();
const first = f.edit.addNode('FirstNotEmpty');
const second = f.edit.addNode('FirstNotEmpty');
f.edit.wire([
[f.edit.addNode('Number'), 'value', first, 'a'],
[f.edit.addNode('Number'), 'value', first, 'b'],
[first, 'value', second, 'a'],
[first, 'value', second, 'b'],
]);
expect(typeOf(f.behavior, first, 'value', f)).toBe('number');
// A chain would have to resolve its neighbour to answer, and two of them would ask in circles.
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
});
it('leaves an edge that the new type makes invalid in place, for the canvas to mark', () => {
const f = fixture();
const node = f.edit.addNode('FirstNotEmpty');
@@ -167,6 +152,184 @@ describe('Select, whose false, true and value carry the same value', () => {
});
});
type Fixture = ReturnType<typeof fixture>;
/** `count` First not empty in a row: the first fed by two literals, each next one by the one before. */
function chain(f: Fixture, count: number, literal: string, kind = 'FirstNotEmpty'): GraphNode[] {
const [a, b] = kind === 'Select' ? ['false', 'true'] : ['a', 'b'];
const nodes = Array.from({ length: count }, () => f.edit.addNode(kind));
f.edit.connect(f.edit.addNode(literal), 'value', nodes[0]!, a);
f.edit.connect(f.edit.addNode(literal), 'value', nodes[0]!, b);
for (let i = 1; i < count; i++) {
f.edit.connect(nodes[i - 1]!, 'value', nodes[i]!, a);
f.edit.connect(nodes[i - 1]!, 'value', nodes[i]!, b);
}
return nodes;
}
/** Two First not empty feeding each other, each with a number on its other input. */
function circle(f: Fixture): [GraphNode, GraphNode] {
const first = f.edit.addNode('FirstNotEmpty');
const second = f.edit.addNode('FirstNotEmpty');
f.edit.wire([
[f.edit.addNode('Number'), 'value', first, 'b'],
[f.edit.addNode('Number'), 'value', second, 'b'],
[first, 'value', second, 'a'],
[second, 'value', first, 'a'],
]);
return [first, second];
}
describe('a type handed on through several nodes with a shared group (#210)', () => {
it('reaches the second node of a chain', () => {
const f = fixture();
const [first, second] = chain(f, 2, 'Number');
expect(typeOf(f.behavior, first!, 'value', f)).toBe('number');
expect(typeOf(f.behavior, second!, 'value', f)).toBe('number');
});
it('reaches the third, and keeps every input on the way any', () => {
const f = fixture();
const nodes = chain(f, 3, 'String');
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['string', 'string', 'string']);
expect(nodes.map((n) => typeOf(f.behavior, n, 'a', f, 'inputs'))).toEqual(['any', 'any', 'any']);
});
it('passes from one kind of node to another', () => {
const f = fixture();
const [pick] = chain(f, 1, 'Animation');
const select = f.edit.addNode('Select');
f.edit.wire([
[pick!, 'value', select, 'false'],
[f.edit.addNode('Animation'), 'value', select, 'true'],
]);
const animation = typeOf(f.behavior, pick!, 'value', f);
expect(animation).not.toBe('any');
expect(typeOf(f.behavior, select, 'value', f)).toBe(animation);
});
it('is any behind a node whose inputs disagree, and untouched in front of it', () => {
const f = fixture();
const nodes = chain(f, 4, 'Number');
// The second node now hears a number and a text.
f.edit.connect(f.edit.addNode('String'), 'value', nodes[1]!, 'b');
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'any', 'any', 'any']);
});
it('is any behind a node with an input left open', () => {
const f = fixture();
const first = f.edit.addNode('FirstNotEmpty');
const second = f.edit.addNode('FirstNotEmpty');
f.edit.wire([
[f.edit.addNode('Number'), 'value', first, 'a'],
[first, 'value', second, 'a'],
[first, 'value', second, 'b'],
]);
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
});
it('falls back to any all along the chain when an edge at its front is undone', () => {
const f = fixture();
const nodes = Array.from({ length: 3 }, () => f.edit.addNode('FirstNotEmpty'));
f.edit.wire([
[nodes[0]!, 'value', nodes[1]!, 'a'],
[nodes[0]!, 'value', nodes[1]!, 'b'],
[nodes[1]!, 'value', nodes[2]!, 'a'],
[nodes[1]!, 'value', nodes[2]!, 'b'],
[f.edit.addNode('Number'), 'value', nodes[0]!, 'a'],
[f.edit.addNode('Number'), 'value', nodes[0]!, 'b'],
]);
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'number', 'number']);
f.stack.undo();
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['any', 'any', 'any']);
f.stack.redo();
expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'number', 'number']);
});
it('ends a circle of two with any instead of asking forever', () => {
const f = fixture();
const [first, second] = circle(f);
expect(typeOf(f.behavior, first, 'value', f)).toBe('any');
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
});
it('ends a node feeding itself with any', () => {
const f = fixture();
const node = f.edit.addNode('FirstNotEmpty');
f.edit.connect(f.edit.addNode('Number'), 'value', node, 'b');
// No command lays this edge, a file written by hand can still carry it.
f.behavior.graph.edges.push({
id: 'loop',
from: { node: node.id, port: 'value' },
to: { node: node.id, port: 'a' },
});
expect(typeOf(f.behavior, node, 'value', f)).toBe('any');
});
it('is any behind a circle as well', () => {
const f = fixture();
const [, second] = circle(f);
const behind = f.edit.addNode('FirstNotEmpty');
f.edit.wire([
[second, 'value', behind, 'a'],
[f.edit.addNode('Number'), 'value', behind, 'b'],
]);
expect(typeOf(f.behavior, behind, 'value', f)).toBe('any');
});
it('makes an edge further down the chain invalid, and leaves it in place', () => {
const f = fixture();
const first = f.edit.addNode('FirstNotEmpty');
const second = f.edit.addNode('FirstNotEmpty');
const sub = f.edit.addNode('Substring');
f.edit.wire([
[second, 'value', sub, 'text'],
[first, 'value', second, 'a'],
[first, 'value', second, 'b'],
]);
const lookup = () => graphLookup(f.project);
const out = () =>
portTypeOf(f.behavior.graph, { nodeId: second.id, port: 'value', isOutput: true }, lookup())!;
const inp = () =>
portTypeOf(f.behavior.graph, { nodeId: sub.id, port: 'text', isOutput: false }, lookup())!;
expect(portsCompatible(out(), inp())).toBe(true);
f.edit.wire([
[f.edit.addNode('Number'), 'value', first, 'a'],
[f.edit.addNode('Number'), 'value', first, 'b'],
]);
expect(portsCompatible(out(), inp())).toBe(false);
expect(f.behavior.graph.edges.filter((e) => e.to.node === sub.id)).toHaveLength(1);
});
it('asks a node feeding both inputs of the next only once, however long the chain', () => {
const f = fixture();
// Asked once per path, the last of 40 would be asked 2^40 times and this test would never end.
const nodes = chain(f, 40, 'Number', 'Select');
expect(typeOf(f.behavior, nodes[39]!, 'value', f)).toBe('number');
});
it('resolves every node of a chain of 50 well within a frame', () => {
const f = fixture();
const nodes = chain(f, 50, 'Number');
const lookup = graphLookup(f.project);
const all = () => {
for (const node of f.behavior.graph.nodes)
drawnDef(node, f.behavior.graph, resolveDef(node, f.behavior.graph, lookup)!, lookup);
};
all();
const start = performance.now();
for (let i = 0; i < 20; i++) all();
const each = (performance.now() - start) / 20;
expect(typeOf(f.behavior, nodes[49]!, 'value', f)).toBe('number');
// What the canvas does on every change to the project. Measured at 10 ms on the slowest host,
// against 2 ms before chains; the bound only has to catch a walk that grows with every path.
expect(each).toBeLessThan(200);
});
});
/** As the canvas draws it: `drawnDef` is the only place a pin colour is decided. */
const inputPin = (behavior: BehaviorAsset, node: GraphNode, key: string, f: ProjectFixture): PortDef => {
const lookup = graphLookup(f.project);
@@ -223,11 +386,25 @@ describe('an any input painted as what flows into it', () => {
[f.edit.addNode('Number'), 'value', first, 'b'],
[first, 'value', second, 'a'],
]);
// The edge is drawn in number, so the pin it ends on is too – the type is not passed on.
// The edge is drawn in number, so the pin it ends on is too. Its other input is still open.
expect(inputPin(f.behavior, second, 'a', f).paintType).toBe('number');
expect(typeOf(f.behavior, second, 'value', f)).toBe('any');
});
it('follows a type handed on through a chain, two nodes back', () => {
const f = fixture();
const nodes = chain(f, 3, 'Number');
expect(inputPin(f.behavior, nodes[2]!, 'a', f).paintType).toBe('number');
expect(inputPin(f.behavior, nodes[2]!, 'b', f).paintType).toBe('number');
});
it('stays grey on every pin of a circle', () => {
const f = fixture();
const [first, second] = circle(f);
expect(inputPin(f.behavior, first, 'a', f).paintType).toBeUndefined();
expect(inputPin(f.behavior, second, 'a', f).paintType).toBeUndefined();
});
it('leaves an input with a type of its own alone', () => {
const f = fixture();
const sub = f.edit.addNode('Substring');
+35
View File
@@ -1050,6 +1050,41 @@ export async function graph(t) {
JSON.stringify({ pinColors, wanted }),
);
await shot('20-input-pin-colours');
// A second node of the kind behind the first (#210): the type is handed on through the chain,
// and taken back all along it once the front of the chain disagrees again.
await mutate(`
__gm.state.project.assets
.find((a) => a.id === 'picky')
.graph.nodes.push({ id: 'p-next', type: 'FirstNotEmpty', x: 300, y: 330, params: {} });
`);
const nextTypes = async () => json(`__gm.graph.portTypes('p-next')`);
await wire('p-pick', 'value', 'p-next', 'a');
await wire('p-pick', 'value', 'p-next', 'b');
const chainOpen = await nextTypes();
await wire('p-a', 'value', 'p-pick', 'a');
const chainTyped = await nextTypes();
check(
'a second node of the kind behind the first takes the type the first one agreed on',
chainOpen.outputs.value === 'any' &&
(await pickTypes()).outputs.value === 'number' &&
chainTyped.outputs.value === 'number' &&
chainTyped.inputs.a === 'any' &&
chainTyped.paint.a === 'number' &&
chainTyped.paint.b === 'number',
JSON.stringify({ chainOpen, chainTyped }),
);
await shot('20b-shared-type-chain');
await wire('p-text', 'value', 'p-pick', 'a');
const chainMixed = await nextTypes();
check(
'a text plugged into the first opens the whole chain up again',
(await pickTypes()).outputs.value === 'any' &&
chainMixed.outputs.value === 'any' &&
chainMixed.paint.a === 'any' &&
chainMixed.paint.b === 'any',
JSON.stringify(chainMixed),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'picky');