Engineering Journal
Schema Editor
Schema Editor

The Graph Bug Where Nodes Exist But Nothing Connects to Them

2026-07-17

TLDR

A two-pass graph builder that creates nodes in pass 1 and links edges in pass 2 will produce orphan nodes if edge-resolution logic is narrower than node-creation logic. We experienced this exact defect: junction dots were detected and stored as graph nodes, but wire-edge resolution only scanned component pins. Any connection through a junction silently vanished. We fixed this by implementing a unified fallback resolution chain and integrating a Union-Find (Disjoint-Set) data structure to manage transitive net connectivity.
Builder PhaseInitial DefectRefactored ArchitectureGraph Result
Node DiscoveryCreated junctions (nodes.set(jid, ...))Created junctions (nodes.set(jid, ...))Junction nodes present
Edge ResolutionChecked Component Pins ONLYFallback Chain (Pin $\rightarrow$ Junction)Edges correctly linked
Transitive QueryTraversed empty adjacency listsUnion-Find Disjoint Set (wire.net)Instant 60fps Net Tracing

Problem statement: the invisible orphan node defect

In electrical schematics, 3-way wire junctions connect multiple conductors into a single electrical net.

During interactive trace mode, hovering a wire should highlight all connected wires and components across the entire signal path.

The bug

Hovering a wire highlighted only the two directly attached components. Highlighting stopped dead at every T-junction dot. In the debugger, the junction node existed in memory, but its adjacency list was completely empty.

Technical failure mode: asymmetric two-pass construction

The root cause was an architectural mismatch between Pass 1 (Node Creation) and Pass 2 (Edge Resolution):

// PASS 1: Node Creation (Discovers junctions correctly)
epMap.forEach((wireIds, key) => {
  if (wireIds.length >= 3) {
    nodes.set(jid, { kind: 'junction', x, y });
    adjacency.set(jid, []); // Initialized... but never populated!
  }
});

// DEFECTIVE PASS 2: Edge Resolution (Checked COMPONENT PINS ONLY!) const fromNode = portMatches.find(nearEndpoint)?.compId || null; // Wire endpoints landing on junctions resolved to null, dropping the edge!

Because Pass 2 only searched component pins, wire endpoints terminating at junction dots returned null. The edges were dropped, leaving junction nodes isolated in memory.


The fix & architecture: fallback chains & union-find sets

Step 1: fallback endpoint resolution chain

Unify endpoint resolution into a single function that checks component pins first, then falls back to junction nodes:
function resolveEndpointNode(ep, portMatches, junctionMap) {
  // 1. Check Component Pins
  const pinMatch = portMatches.find(p => isNear(p, ep));
  if (pinMatch) return pinMatch.compId;

// 2. Fallback to Junction Nodes const junctionId = junctionMap.get(coordKey(ep)); if (junctionId) return junctionId;

return null; // Unconnected endpoint }

Step 2: transitive net resolution via union-find

Instead of traversing adjacency graphs on every hover query, use a Union-Find (Disjoint-Set) data structure during graph assembly to assign net IDs:
// Union-Find Disjoint Set Assembly
const parent = new Map();
function find(i) {
  if (!parent.has(i)) parent.set(i, i);
  if (parent.get(i) === i) return i;
  parent.set(i, find(parent.get(i))); // Path compression
  return parent.get(i);
}
function union(i, j) {
  const rootI = find(i);
  const rootJ = find(j);
  if (rootI !== rootJ) parent.set(rootI, rootJ);
}

// Merge wire endpoints into transitive nets wires.forEach(w => { union(coordKey(w.endpoints[0]), coordKey(w.endpoints[1])); });

// Assign computed net ID to each wire for O(1) trace queries wires.forEach(w => { w.netId = find(coordKey(w.endpoints[0])); });

Rule of thumb: Never separate node creation from edge resolution without a shared endpoint resolver. Use Union-Find (Disjoint-Set) structures to evaluate transitive net connectivity in $O(1)$ time.
Read this post in the full Engineering Journal →