capdag 0.109.248 → 0.124.274

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,7 +185,7 @@ function layoutForMode(mode) {
185
185
  'elk.spacing.nodeNode': 35,
186
186
  });
187
187
  }
188
- if (mode === 'machine') {
188
+ if (mode === 'editor-graph') {
189
189
  // Editor graph is a small bipartite-ish DAG; orthogonal routing
190
190
  // reads more cleanly than polyline at this density.
191
191
  return {
@@ -199,6 +199,16 @@ function layoutForMode(mode) {
199
199
  'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
200
200
  };
201
201
  }
202
+ if (mode === 'machine') {
203
+ // Resolved-machine graph: a (potentially multi-strand) DAG laid
204
+ // out left-to-right with the same spacing the strand mode uses
205
+ // for single strands. Multiple disconnected strands are placed
206
+ // side by side by elk's component packer.
207
+ return Object.assign({}, base, {
208
+ 'elk.layered.spacing.nodeNodeBetweenLayers': 120,
209
+ 'elk.spacing.nodeNode': 40,
210
+ });
211
+ }
202
212
  throw new Error(`CapGraphRenderer: unknown mode '${mode}'`);
203
213
  }
204
214
 
@@ -222,6 +232,16 @@ function buildStylesheet() {
222
232
  const bodyEdgeSuccess = getCssVar('--graph-body-edge-success');
223
233
  const bodyEdgeFailure = getCssVar('--graph-body-edge-failure');
224
234
 
235
+ // Machine mode uses a dedicated palette exposed by the notation
236
+ // editor's CSS (`--graph-node-color`, `--graph-loop-color`,
237
+ // `--graph-edge-color`, `--graph-text`, `--graph-muted`). Caps
238
+ // are collapsed into edges so there's no cap-node color.
239
+ const machineNodeColor = getCssVar('--graph-node-color');
240
+ const machineLoopColor = getCssVar('--graph-loop-color');
241
+ const machineEdgeColor = getCssVar('--graph-edge-color');
242
+ const machineText = getCssVar('--graph-text');
243
+ const machineMuted = getCssVar('--graph-muted');
244
+
225
245
  return [
226
246
  {
227
247
  selector: 'node',
@@ -271,7 +291,11 @@ function buildStylesheet() {
271
291
  {
272
292
  selector: 'node.show-more',
273
293
  style: {
274
- 'background-color': getCssVar('--graph-bg'),
294
+ // Use the normal node fill; the dashed border is what
295
+ // distinguishes a show-more node from a regular cap.
296
+ // The renderer never reads `--graph-bg` — the graph
297
+ // canvas background is entirely the host's concern.
298
+ 'background-color': nodeBg,
275
299
  'border-style': 'dashed',
276
300
  'border-width': '2px',
277
301
  'border-color': nodeBorderHighlighted,
@@ -286,11 +310,14 @@ function buildStylesheet() {
286
310
  'font-weight': '500',
287
311
  'color': 'data(color)',
288
312
  'text-background-color': edgeTextBg,
289
- 'text-background-opacity': edgeTextBgOpacity,
290
- 'text-background-padding': '3px',
313
+ 'text-background-opacity': 1,
314
+ 'text-background-padding': '4px',
291
315
  'text-background-shape': 'roundrectangle',
292
- 'text-rotation': 'autorotate',
293
- 'text-margin-y': -8,
316
+ // Keep labels horizontal — autorotate clips along
317
+ // fan-out edges and rotates labels into unreadable
318
+ // angles when multiple edges share a source.
319
+ 'text-rotation': 0,
320
+ 'text-margin-y': 0,
294
321
  'curve-style': 'bezier',
295
322
  'control-point-step-size': 40,
296
323
  'width': 1.5,
@@ -316,11 +343,21 @@ function buildStylesheet() {
316
343
  },
317
344
  {
318
345
  selector: 'edge.body-success',
319
- style: { 'line-color': bodyEdgeSuccess, 'target-arrow-color': bodyEdgeSuccess },
346
+ style: {
347
+ 'line-color': bodyEdgeSuccess,
348
+ 'target-arrow-color': bodyEdgeSuccess,
349
+ // Label text uses the resolved success color too, not the
350
+ // unresolved `var(...)` string stored in `data.color`.
351
+ 'color': bodyEdgeSuccess,
352
+ },
320
353
  },
321
354
  {
322
355
  selector: 'edge.body-failure',
323
- style: { 'line-color': bodyEdgeFailure, 'target-arrow-color': bodyEdgeFailure },
356
+ style: {
357
+ 'line-color': bodyEdgeFailure,
358
+ 'target-arrow-color': bodyEdgeFailure,
359
+ 'color': bodyEdgeFailure,
360
+ },
324
361
  },
325
362
  {
326
363
  selector: 'node.path-highlighted',
@@ -330,6 +367,44 @@ function buildStylesheet() {
330
367
  selector: 'edge.path-highlighted',
331
368
  style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
332
369
  },
370
+
371
+ // -------- Machine / editor-graph mode ------------------------------
372
+ // Shared between the resolved-machine view (`mode: 'machine'`,
373
+ // canonical `Machine::to_render_payload_json`) and the notation
374
+ // editor's live graph (`mode: 'editor-graph'`, ast-driven). Both
375
+ // collapse each cap application into a single labeled edge
376
+ // between its input and output data slots — caps do NOT appear
377
+ // as separate nodes. Only data slots are nodes; cap semantics
378
+ // are carried on edges.
379
+ //
380
+ // Reads the editor's dedicated palette so nodes, edges and
381
+ // loop markers match the text theme.
382
+ {
383
+ selector: 'node.machine-node',
384
+ style: {
385
+ 'background-color': machineNodeColor || nodeBg,
386
+ 'border-color': machineNodeColor || nodeBorder,
387
+ 'color': machineText || nodeText,
388
+ 'border-opacity': 1,
389
+ },
390
+ },
391
+ {
392
+ selector: 'edge.machine-edge',
393
+ style: {
394
+ 'line-color': machineEdgeColor || 'data(color)',
395
+ 'target-arrow-color': machineEdgeColor || 'data(color)',
396
+ 'color': machineMuted || nodeText,
397
+ },
398
+ },
399
+ {
400
+ selector: 'edge.machine-loop',
401
+ style: {
402
+ 'line-color': machineLoopColor || nodeBorderActive,
403
+ 'target-arrow-color': machineLoopColor || nodeBorderActive,
404
+ 'color': machineLoopColor || nodeBorderActive,
405
+ 'line-style': 'dashed',
406
+ },
407
+ },
333
408
  ];
334
409
  }
335
410
 
@@ -444,6 +519,9 @@ function validateStrandPayload(data) {
444
519
  && (data.media_display_names === null || typeof data.media_display_names !== 'object')) {
445
520
  throw new Error('CapGraphRenderer strand mode: data.media_display_names must be an object when present');
446
521
  }
522
+ if (data.source_display !== undefined && typeof data.source_display !== 'string') {
523
+ throw new Error('CapGraphRenderer strand mode: data.source_display must be a string when present');
524
+ }
447
525
  }
448
526
 
449
527
  function validateBodyOutcome(outcome, path) {
@@ -495,25 +573,93 @@ function validateRunPayload(data) {
495
573
  }
496
574
  }
497
575
 
498
- function validateMachinePayload(data) {
576
+ function validateEditorGraphPayload(data) {
499
577
  if (!data || typeof data !== 'object') {
500
- throw new Error('CapGraphRenderer machine mode: data must be an object');
578
+ throw new Error('CapGraphRenderer editor-graph mode: data must be an object');
501
579
  }
502
- assertArray(data.elements, 'machine mode data.elements');
580
+ assertArray(data.elements, 'editor-graph mode data.elements');
503
581
  data.elements.forEach((el, idx) => {
504
582
  if (!el || typeof el !== 'object') {
505
- throw new Error(`CapGraphRenderer machine mode: data.elements[${idx}] is not an object`);
583
+ throw new Error(`CapGraphRenderer editor-graph mode: data.elements[${idx}] is not an object`);
506
584
  }
507
585
  if (el.kind !== 'node' && el.kind !== 'cap' && el.kind !== 'edge') {
508
586
  throw new Error(
509
- `CapGraphRenderer machine mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
587
+ `CapGraphRenderer editor-graph mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
510
588
  );
511
589
  }
512
- assertString(el.graph_id, `machine mode data.elements[${idx}].graph_id`);
590
+ assertString(el.graph_id, `editor-graph mode data.elements[${idx}].graph_id`);
513
591
  if (el.kind === 'edge') {
514
- assertString(el.source_graph_id, `machine mode data.elements[${idx}].source_graph_id`);
515
- assertString(el.target_graph_id, `machine mode data.elements[${idx}].target_graph_id`);
592
+ assertString(el.source_graph_id, `editor-graph mode data.elements[${idx}].source_graph_id`);
593
+ assertString(el.target_graph_id, `editor-graph mode data.elements[${idx}].target_graph_id`);
594
+ }
595
+ });
596
+ }
597
+
598
+ // `validateResolvedMachinePayload` checks the canonical machine
599
+ // render payload produced by Rust `Machine::to_render_payload_json`.
600
+ // Shape:
601
+ // {
602
+ // "strands": [
603
+ // {
604
+ // "nodes": [{"id": "n0", "urn": "media:pdf"}, ...],
605
+ // "edges": [{
606
+ // "alias": "edge_0",
607
+ // "cap_urn": "...",
608
+ // "is_loop": false,
609
+ // "assignment": [{"cap_arg_media_urn": "media:pdf", "source_node": "n0"}, ...],
610
+ // "target_node": "n1"
611
+ // }, ...],
612
+ // "input_anchor_nodes": ["n0"],
613
+ // "output_anchor_nodes": ["n2"]
614
+ // },
615
+ // ...
616
+ // ]
617
+ // }
618
+ function validateResolvedMachinePayload(data) {
619
+ if (!data || typeof data !== 'object') {
620
+ throw new Error('CapGraphRenderer machine mode: data must be an object');
621
+ }
622
+ assertArray(data.strands, 'machine mode data.strands');
623
+ data.strands.forEach((strand, sIdx) => {
624
+ if (!strand || typeof strand !== 'object') {
625
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}] is not an object`);
516
626
  }
627
+ assertArray(strand.nodes, `machine mode data.strands[${sIdx}].nodes`);
628
+ strand.nodes.forEach((n, nIdx) => {
629
+ if (!n || typeof n !== 'object') {
630
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].nodes[${nIdx}] is not an object`);
631
+ }
632
+ assertString(n.id, `machine mode data.strands[${sIdx}].nodes[${nIdx}].id`);
633
+ assertString(n.urn, `machine mode data.strands[${sIdx}].nodes[${nIdx}].urn`);
634
+ });
635
+ assertArray(strand.edges, `machine mode data.strands[${sIdx}].edges`);
636
+ strand.edges.forEach((e, eIdx) => {
637
+ if (!e || typeof e !== 'object') {
638
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}] is not an object`);
639
+ }
640
+ assertString(e.alias, `machine mode data.strands[${sIdx}].edges[${eIdx}].alias`);
641
+ assertString(e.cap_urn, `machine mode data.strands[${sIdx}].edges[${eIdx}].cap_urn`);
642
+ if (typeof e.is_loop !== 'boolean') {
643
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}].is_loop must be boolean`);
644
+ }
645
+ assertArray(e.assignment, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment`);
646
+ e.assignment.forEach((b, bIdx) => {
647
+ if (!b || typeof b !== 'object') {
648
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}] is not an object`);
649
+ }
650
+ assertString(b.cap_arg_media_urn, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}].cap_arg_media_urn`);
651
+ assertString(b.source_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}].source_node`);
652
+ });
653
+ assertString(e.target_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].target_node`);
654
+ });
655
+ assertArray(strand.input_anchor_nodes, `machine mode data.strands[${sIdx}].input_anchor_nodes`);
656
+ strand.input_anchor_nodes.forEach((id, iIdx) => {
657
+ assertString(id, `machine mode data.strands[${sIdx}].input_anchor_nodes[${iIdx}]`);
658
+ });
659
+ assertArray(strand.output_anchor_nodes, `machine mode data.strands[${sIdx}].output_anchor_nodes`);
660
+ strand.output_anchor_nodes.forEach((id, oIdx) => {
661
+ assertString(id, `machine mode data.strands[${sIdx}].output_anchor_nodes[${oIdx}]`);
662
+ });
517
663
  });
518
664
  }
519
665
 
@@ -739,7 +885,8 @@ function buildStrandGraphData(data) {
739
885
  });
740
886
  }
741
887
  let edgeCounter = 0;
742
- function addEdge(source, target, label, title, fullUrn, edgeClass) {
888
+ function addEdge(source, target, label, title, fullUrn, edgeClass, meta) {
889
+ const m = meta || {};
743
890
  edges.push({
744
891
  id: `strand-edge-${edgeCounter}`,
745
892
  source,
@@ -749,6 +896,11 @@ function buildStrandGraphData(data) {
749
896
  fullUrn: fullUrn || '',
750
897
  edgeClass: edgeClass || '',
751
898
  color: edgeHueColor(edgeCounter),
899
+ // `foreachEntry` flags a cap edge as the first cap entering a
900
+ // ForEach body (the "phantom direct edge" in plan builder's
901
+ // terminology). Render-time collapse uses this to relabel the
902
+ // edge with the cap title + (1→n) marker. Defaults to false.
903
+ foreachEntry: m.foreachEntry === true,
752
904
  });
753
905
  edgeCounter++;
754
906
  }
@@ -768,13 +920,14 @@ function buildStrandGraphData(data) {
768
920
  let bodyExit = null;
769
921
 
770
922
  // Finalize an outer ForEach body when a nested ForEach starts before
771
- // the outer's Collect. Mirrors plan_builder.rs:238-289.
923
+ // the outer's Collect. Mirrors plan_builder.rs:238-289. The render
924
+ // collapse will later drop the ForEach node and synthesize the
925
+ // bridging edges, so we only need to emit the plan-builder
926
+ // topology here.
772
927
  function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
773
928
  const outerForEachInput = outerForEach.index === 0
774
929
  ? inputSlotId
775
930
  : `step_${outerForEach.index - 1}`;
776
- // Create the ForEach node + direct edge from its input + iteration
777
- // edge into the body's first cap.
778
931
  addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
779
932
  addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
780
933
  addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
@@ -790,12 +943,27 @@ function buildStrandGraphData(data) {
790
943
  const toCanonical = canonicalMediaUrn(step.to_spec);
791
944
  addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
792
945
 
946
+ // The first cap inside a ForEach body is the "foreach entry"
947
+ // — its incoming edge crosses the foreach boundary. Strand
948
+ // mode's render collapse relabels that edge with a (1→n)
949
+ // cardinality marker regardless of the cap's own sequence
950
+ // flags, because visually the transition IS the foreach.
951
+ const isForeachEntry = insideForEachBody !== null && bodyEntry === null;
952
+
793
953
  let label = body.title;
794
954
  const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
795
955
  if (cardinality !== '1\u21921') {
796
956
  label = `${label} (${cardinality})`;
797
957
  }
798
- addEdge(prevNodeId, nodeId, label, body.title, body.cap_urn, 'strand-cap-edge');
958
+ addEdge(
959
+ prevNodeId,
960
+ nodeId,
961
+ label,
962
+ body.title,
963
+ body.cap_urn,
964
+ 'strand-cap-edge',
965
+ { foreachEntry: isForeachEntry }
966
+ );
799
967
 
800
968
  if (insideForEachBody !== null) {
801
969
  if (bodyEntry === null) bodyEntry = nodeId;
@@ -854,7 +1022,11 @@ function buildStrandGraphData(data) {
854
1022
  prevNodeId = nodeId;
855
1023
  } else {
856
1024
  // Standalone Collect — scalar → list-of-one. Mirrors
857
- // plan_builder.rs:333-355.
1025
+ // plan_builder.rs:333-355. There is no enclosing foreach
1026
+ // body, so the preceding cap is NOT flagged as a
1027
+ // foreach-exit; the render-time collapse will synthesize a
1028
+ // plain "collect" marker on the synthesized edge replacing
1029
+ // the dropped Collect node.
858
1030
  addNode(nodeId, 'collect', '', 'strand-collect');
859
1031
  addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
860
1032
  prevNodeId = nodeId;
@@ -866,6 +1038,10 @@ function buildStrandGraphData(data) {
866
1038
  });
867
1039
 
868
1040
  // Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
1041
+ // An unclosed ForEach with a body just creates the synthetic
1042
+ // ForEach node + iteration edge and leaves `prev_node_id` at
1043
+ // the last body cap. The render collapse drops the ForEach node;
1044
+ // the cap edges inside the body keep their own labels verbatim.
869
1045
  if (insideForEachBody !== null) {
870
1046
  const outer = insideForEachBody;
871
1047
  const hasBodyEntry = bodyEntry !== null;
@@ -892,11 +1068,180 @@ function buildStrandGraphData(data) {
892
1068
  addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
893
1069
  addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
894
1070
 
1071
+ // Return the raw plan-builder topology. Strand mode collapses
1072
+ // ForEach/Collect nodes into edge labels at render time (see
1073
+ // `strandCytoscapeElements`); run mode keeps them as explicit
1074
+ // nodes because body replicas anchor at the ForEach/Collect
1075
+ // junctions.
895
1076
  return { nodes, edges, sourceSpec, targetSpec };
896
1077
  }
897
1078
 
898
- function strandCytoscapeElements(built) {
899
- const nodeElements = built.nodes.map(node => ({
1079
+ // Transform the plan-builder strand topology into the render shape
1080
+ // strand mode actually displays. Pure function; does NOT mutate the
1081
+ // input. Run mode bypasses this transform and consumes the raw
1082
+ // topology directly (see `runCytoscapeElements`).
1083
+ //
1084
+ // The display rules (per user spec):
1085
+ //
1086
+ // 1. ForEach and Collect are NOT rendered as nodes. They're
1087
+ // execution-layer concepts; the visible semantic is carried on
1088
+ // the surrounding cap edges.
1089
+ //
1090
+ // 2. The first cap edge entering a ForEach body is relabeled to
1091
+ // `<cap_title> (1→n)`. The builder flags those edges with
1092
+ // `foreachEntry: true`. The collapse does NOT relabel this
1093
+ // edge — the cap's own cardinality marker (from its
1094
+ // `input_is_sequence`/`output_is_sequence`) is the single
1095
+ // source of truth. A `(1→n)` marker on a strand edge means
1096
+ // the cap on that edge produces a sequence, and that's
1097
+ // already reflected in the builder's label.
1098
+ //
1099
+ // 3. Every Collect (whether closing a body or standalone) is
1100
+ // replaced by a plain unlabeled bridging edge from the
1101
+ // body-exit node to the post-collect target. Any cardinality
1102
+ // shift introduced by the collect is already visible on the
1103
+ // post-collect cap's `input_is_sequence=true` flag.
1104
+ //
1105
+ // 4. If the last cap step's `to_spec` is semantically equivalent
1106
+ // to the strand's `target_spec` (via MediaUrn.isEquivalent),
1107
+ // the separate `output` target node is dropped and the last
1108
+ // cap edge lands on that merged endpoint. Removes the visible
1109
+ // duplicate node.
1110
+ function collapseStrandShapeTransitions(built) {
1111
+ const MediaUrn = requireHostDependency('MediaUrn');
1112
+
1113
+ // Index for lookups.
1114
+ const nodeById = new Map();
1115
+ for (const n of built.nodes) nodeById.set(n.id, n);
1116
+
1117
+ // Step 1: before dropping Collect nodes, synthesize a plain
1118
+ // bridging edge that replaces each dropped Collect. For a
1119
+ // Collect node C the plan builder produced:
1120
+ // body_exit → C (strand-collection, label="collect")
1121
+ // C → next (strand-cap-edge, label="")
1122
+ // The collapse drops C and its two touching edges. We emit an
1123
+ // unlabeled `body_exit → next` cap edge in their place. The
1124
+ // Collect transition itself is invisible — any cardinality
1125
+ // shift is already on the post-collect cap's own label.
1126
+ const synthesizedExitEdges = [];
1127
+ for (const node of built.nodes) {
1128
+ if (node.nodeClass !== 'strand-collect') continue;
1129
+ const incoming = built.edges.filter(e =>
1130
+ e.target === node.id && e.edgeClass === 'strand-collection');
1131
+ const outgoing = built.edges.filter(e =>
1132
+ e.source === node.id && e.edgeClass === 'strand-cap-edge');
1133
+ for (const inEdge of incoming) {
1134
+ for (const outEdge of outgoing) {
1135
+ const bodyExitNodeId = inEdge.source;
1136
+ const bodyExitCapEdge = built.edges.find(e =>
1137
+ e.edgeClass === 'strand-cap-edge' && e.target === bodyExitNodeId);
1138
+ // The Collect transition is invisible in the render.
1139
+ // Every Collect becomes a plain unlabeled bridge edge
1140
+ // from the body-exit node to the post-collect target.
1141
+ // Any cardinality shift is already visible on the
1142
+ // body-exit cap's own label (via its input/output
1143
+ // sequence flags) or on the post-collect cap's label.
1144
+ synthesizedExitEdges.push({
1145
+ id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
1146
+ source: bodyExitNodeId,
1147
+ target: outEdge.target,
1148
+ label: '',
1149
+ title: '',
1150
+ fullUrn: '',
1151
+ edgeClass: 'strand-cap-edge',
1152
+ color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
1153
+ foreachEntry: false,
1154
+ });
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ // Drop all ForEach/Collect nodes and every edge that touches
1160
+ // them (direct, iteration, collection, and the trailing collect
1161
+ // cap-edge connector). The render never shows those nodes.
1162
+ const dropNodeIds = new Set();
1163
+ for (const node of built.nodes) {
1164
+ if (node.nodeClass === 'strand-foreach' || node.nodeClass === 'strand-collect') {
1165
+ dropNodeIds.add(node.id);
1166
+ }
1167
+ }
1168
+ let nodes = built.nodes.filter(n => !dropNodeIds.has(n.id));
1169
+ let edges = built.edges.filter(e =>
1170
+ !dropNodeIds.has(e.source) &&
1171
+ !dropNodeIds.has(e.target) &&
1172
+ e.edgeClass !== 'strand-iteration' &&
1173
+ e.edgeClass !== 'strand-collection');
1174
+ edges = edges.concat(synthesizedExitEdges);
1175
+
1176
+ // Step 2: foreach-entry cap edges keep whatever label the
1177
+ // strand builder emitted — the cap's own cardinality marker
1178
+ // (from input_is_sequence/output_is_sequence) is the single
1179
+ // source of truth for which edge carries a (1→n) / (n→1) /
1180
+ // (n→n) annotation. The ForEach/Collect shape transitions
1181
+ // themselves are invisible in the render; the cap preceding a
1182
+ // ForEach (with output_is_sequence=true) already produces the
1183
+ // (1→n) marker, and the cap following a Collect (with
1184
+ // input_is_sequence=true) produces (n→1). No relabeling.
1185
+
1186
+ // Step 3: merge the trailing `step_N → output` edge when step_N
1187
+ // and output represent the same media URN. The strand builder
1188
+ // always emits a separate `output` node with a (possibly empty)
1189
+ // connector edge from the last prev; when the URNs coincide the
1190
+ // output is a visible duplicate.
1191
+ //
1192
+ // Find the `strand-target` node and look for a single incoming
1193
+ // edge from a `strand-cap` (or `strand-source`) node. Compare the
1194
+ // endpoints' `fullUrn` semantically.
1195
+ const targetNode = nodes.find(n => n.nodeClass === 'strand-target');
1196
+ if (targetNode) {
1197
+ const incomingToTarget = edges.filter(e => e.target === targetNode.id);
1198
+ if (incomingToTarget.length === 1) {
1199
+ const trailing = incomingToTarget[0];
1200
+ const upstreamNode = nodes.find(n => n.id === trailing.source);
1201
+ if (upstreamNode && upstreamNode.fullUrn && targetNode.fullUrn) {
1202
+ let equivalent = false;
1203
+ try {
1204
+ const a = MediaUrn.fromString(upstreamNode.fullUrn);
1205
+ const b = MediaUrn.fromString(targetNode.fullUrn);
1206
+ equivalent = a.isEquivalent(b);
1207
+ } catch (_) {
1208
+ equivalent = false;
1209
+ }
1210
+ // Merge only if the trailing edge is the unadorned connector
1211
+ // (empty label). A labeled last-cap edge carries meaningful
1212
+ // info and must not be collapsed away.
1213
+ if (equivalent && (!trailing.label || trailing.label.length === 0)) {
1214
+ // Drop the trailing connector edge and the target node.
1215
+ // The upstream node effectively becomes the target visually.
1216
+ // Rename its display label to the target's display to
1217
+ // preserve the user-configured media_display_names entry.
1218
+ edges = edges.filter(e => e.id !== trailing.id);
1219
+ nodes = nodes
1220
+ .filter(n => n.id !== targetNode.id)
1221
+ .map(n => n.id === upstreamNode.id
1222
+ ? Object.assign({}, n, { label: targetNode.label, nodeClass: 'strand-target' })
1223
+ : n);
1224
+ }
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ return { nodes, edges };
1230
+ }
1231
+
1232
+ function strandCytoscapeElements(built, options) {
1233
+ // Strand mode presents ForEach and Collect as edge labels, not as
1234
+ // boxed nodes. Apply the cosmetic collapse right before emitting
1235
+ // cytoscape elements so the underlying plan-builder topology stays
1236
+ // intact for any callers that need it.
1237
+ //
1238
+ // Run mode opts out (`options.collapse === false`) because its body
1239
+ // replicas visually fan out from the ForEach node and merge at the
1240
+ // Collect node — those junctions must remain as explicit graph
1241
+ // nodes for the fan-in/fan-out to be visible.
1242
+ const shouldCollapse = !(options && options.collapse === false);
1243
+ const source = shouldCollapse ? collapseStrandShapeTransitions(built) : built;
1244
+ const nodeElements = source.nodes.map(node => ({
900
1245
  group: 'nodes',
901
1246
  data: {
902
1247
  id: node.id,
@@ -905,7 +1250,7 @@ function strandCytoscapeElements(built) {
905
1250
  },
906
1251
  classes: node.nodeClass || '',
907
1252
  }));
908
- const edgeElements = built.edges.map(edge => ({
1253
+ const edgeElements = source.edges.map(edge => ({
909
1254
  group: 'edges',
910
1255
  data: {
911
1256
  id: edge.id,
@@ -938,31 +1283,21 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
938
1283
  return -1;
939
1284
  }
940
1285
 
941
- // Remove nodes and edges belonging to the ForEach body's interior cap
942
- // steps from a strand backbone. In run mode, these are replaced by
943
- // per-body replicas; keeping the prototype chain alongside the
944
- // replicas produces a confusing double-render. The ForEach and Collect
945
- // nodes themselves, plus their iteration/collection edges, stay.
946
- function stripBodyInteriorFromStrandBackbone(built, steps, foreachStepIdx, collectStepIdx) {
947
- const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
948
- // Collect the positional IDs of body-interior cap steps (the caps
949
- // strictly between ForEach and Collect).
950
- const interiorIds = new Set();
951
- for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
952
- if (Object.keys(steps[i].step_type)[0] === 'Cap') {
953
- interiorIds.add(`step_${i}`);
954
- }
955
- }
956
- if (interiorIds.size === 0) return built;
957
-
958
- const keptNodes = built.nodes.filter(n => !interiorIds.has(n.id));
1286
+ // Remove backbone cap nodes that will be replaced by per-body
1287
+ // replicas from the collapsed strand backbone. Every step_id in
1288
+ // `dropStepIds` is erased along with its incoming and outgoing
1289
+ // edges. Replicas own the per-body rendering of those nodes.
1290
+ //
1291
+ // The function does NOT try to stitch the backbone back together —
1292
+ // the replicas are responsible for connecting the anchor to the
1293
+ // merge target, and the "no outcomes yet" case is handled by
1294
+ // `buildRunGraphData`'s backbone-drop logic which also removes the
1295
+ // now-dangling target node when there are zero successful replicas.
1296
+ function stripRunBackboneReplicaNodes(built, dropStepIds) {
1297
+ if (dropStepIds.size === 0) return built;
1298
+ const keptNodes = built.nodes.filter(n => !dropStepIds.has(n.id));
959
1299
  const keptEdges = built.edges.filter(e =>
960
- !interiorIds.has(e.source) && !interiorIds.has(e.target));
961
-
962
- // After stripping, the ForEach node and the Collect node (or the
963
- // output node if no Collect) may become disconnected — the body
964
- // replicas will bridge them. That's fine; cytoscape's ELK layout
965
- // handles disconnected subgraphs.
1300
+ !dropStepIds.has(e.source) && !dropStepIds.has(e.target));
966
1301
  return {
967
1302
  nodes: keptNodes,
968
1303
  edges: keptEdges,
@@ -974,20 +1309,34 @@ function stripBodyInteriorFromStrandBackbone(built, steps, foreachStepIdx, colle
974
1309
  function buildRunGraphData(data) {
975
1310
  validateRunPayload(data);
976
1311
 
977
- // The backbone is rendered with the same rules as strand mode. Feed
978
- // the strand portion to the strand builder to inherit all its
979
- // ForEach/Collect labeling and cardinality-marker logic.
1312
+ // Build the raw strand topology and then apply the strand
1313
+ // collapse so the run backbone inherits the same cosmetic
1314
+ // transform (no ForEach/Collect nodes, fix-up edges for
1315
+ // foreach-entry, merged trailing target).
980
1316
  const strandInput = Object.assign({}, data.resolved_strand, {
981
1317
  media_display_names: data.media_display_names,
982
1318
  });
983
1319
  const strandBuiltRaw = buildStrandGraphData(strandInput);
1320
+ let strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
1321
+
1322
+ // Run mode overrides the input_slot node's label with the
1323
+ // host-supplied `source_display` (runtime input filename).
1324
+ // Strand mode ignores this field so the abstract graph shows
1325
+ // the media spec's title from `media_display_names`.
1326
+ if (typeof data.source_display === 'string' && data.source_display.length > 0) {
1327
+ strandBuiltCollapsed = {
1328
+ nodes: strandBuiltCollapsed.nodes.map(n =>
1329
+ n.id === 'input_slot' ? Object.assign({}, n, { label: data.source_display }) : n),
1330
+ edges: strandBuiltCollapsed.edges,
1331
+ sourceSpec: strandBuiltCollapsed.sourceSpec,
1332
+ targetSpec: strandBuiltCollapsed.targetSpec,
1333
+ };
1334
+ }
984
1335
 
985
- // Locate the ForEach/Collect span in the backbone for body-replica
986
- // placement. The strand builder uses positional node IDs mirroring
987
- // the plan builder (`step_0`, `step_1`, …). The ForEach node at
988
- // step index i has id `step_i`; body replicas fan out from that
989
- // ForEach node and (when a Collect closes the body) merge into the
990
- // Collect node at `step_j`.
1336
+ // Locate the ForEach/Collect span in the raw steps. Positional
1337
+ // IDs survive the collapse (node IDs are `step_${i}` from the
1338
+ // builder), so we can still identify which collapsed nodes
1339
+ // correspond to body-interior caps.
991
1340
  const steps = data.resolved_strand.steps;
992
1341
  let foreachStepIdx = -1;
993
1342
  let collectStepIdx = -1;
@@ -997,17 +1346,7 @@ function buildRunGraphData(data) {
997
1346
  if (variant === 'Collect' && collectStepIdx < 0) collectStepIdx = i;
998
1347
  }
999
1348
  const hasForeach = foreachStepIdx >= 0;
1000
-
1001
- // In run mode we want per-body replicas to REPLACE the prototype cap
1002
- // chain inside the ForEach body, not sit alongside it. Strip the
1003
- // backbone nodes and edges that correspond to body-interior Cap
1004
- // steps and their direct edges, keeping only the input slot, the
1005
- // ForEach node, the Collect node (if any), the output node, and any
1006
- // caps OUTSIDE the body span. Body replicas will connect from the
1007
- // ForEach node and merge at the Collect node.
1008
- const strandBuilt = hasForeach
1009
- ? stripBodyInteriorFromStrandBackbone(strandBuiltRaw, steps, foreachStepIdx, collectStepIdx)
1010
- : strandBuiltRaw;
1349
+ const hasCollect = collectStepIdx >= 0;
1011
1350
 
1012
1351
  // Filter and bound the outcomes.
1013
1352
  const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
@@ -1017,27 +1356,180 @@ function buildRunGraphData(data) {
1017
1356
  const visibleFailure = failures.slice(0, data.visible_failure_count);
1018
1357
  const hiddenSuccessCount = successes.length - visibleSuccess.length;
1019
1358
  const hiddenFailureCount = failures.length - visibleFailure.length;
1359
+ const visibleOutcomes = visibleSuccess.concat(visibleFailure);
1020
1360
 
1021
- // Collect the Cap steps inside the ForEach body. Each body replica
1022
- // chains through these caps.
1361
+ // Per-body replicas only fire when there's a ForEach AND at
1362
+ // least one visible outcome. Without outcomes, the strand
1363
+ // backbone renders the "plan preview" unchanged.
1364
+ const shouldExpand = hasForeach && visibleOutcomes.length > 0;
1365
+
1366
+ // Body-interior Cap steps — each body iteration chains through
1367
+ // these caps once. Only valid when `shouldExpand` is true.
1023
1368
  const bodyCapSteps = [];
1024
- const bodyStart = hasForeach ? foreachStepIdx + 1 : 0;
1025
- const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
1026
- for (let i = bodyStart; i < bodyEnd; i++) {
1027
- if (Object.keys(steps[i].step_type)[0] === 'Cap') {
1028
- bodyCapSteps.push({ globalIndex: i, step: steps[i] });
1369
+ if (hasForeach) {
1370
+ const bodyStart = foreachStepIdx + 1;
1371
+ const bodyEnd = hasCollect ? collectStepIdx : steps.length;
1372
+ for (let i = bodyStart; i < bodyEnd; i++) {
1373
+ if (Object.keys(steps[i].step_type)[0] === 'Cap') {
1374
+ bodyCapSteps.push({ globalIndex: i, step: steps[i] });
1375
+ }
1029
1376
  }
1030
1377
  }
1031
1378
 
1032
- // Body replicas fan out from the ForEach node. If no ForEach, fall
1033
- // back to the input_slot. When the strand closes the body with a
1034
- // Collect, replicas merge into that Collect node; otherwise
1035
- // (unclosed ForEach) they merge into the `output` node.
1036
- const anchorNodeId = hasForeach ? `step_${foreachStepIdx}` : 'input_slot';
1037
- const mergeNodeId = collectStepIdx >= 0 ? `step_${collectStepIdx}` : 'output';
1379
+ // Short-circuit: no outcomes the backbone IS the render.
1380
+ // `anchorNodeId` and `mergeNodeId` are unused in this path.
1381
+ if (!shouldExpand) {
1382
+ return {
1383
+ strandBuilt: strandBuiltCollapsed,
1384
+ replicaNodes: [],
1385
+ replicaEdges: [],
1386
+ showMoreNodes: [],
1387
+ totals: {
1388
+ hiddenSuccessCount,
1389
+ hiddenFailureCount,
1390
+ totalBodyCount: data.total_body_count,
1391
+ visibleSuccessCount: visibleSuccess.length,
1392
+ visibleFailureCount: visibleFailure.length,
1393
+ },
1394
+ };
1395
+ }
1396
+
1397
+ // Expanding. The plan mirrors the working
1398
+ // `machfab-mac/.scrap/working_graph/.../RunGraphViewer.html`:
1399
+ //
1400
+ // pre-foreach backbone ... → [anchor]
1401
+ // │
1402
+ // │ (Disbind PDF Into Pages, fork)
1403
+ // ▼
1404
+ // body_N_entry (per-body page, e.g. "page_3")
1405
+ // │ (Make a Decision)
1406
+ // ▼
1407
+ // body_N_step_0 (per-body Decision output)
1408
+ // │
1409
+ // ▼ (if Collect exists) → shared collect target
1410
+ // (else terminal leaf)
1411
+ //
1412
+ // If the cap immediately before the ForEach has
1413
+ // `output_is_sequence=true`, THAT cap is the fan-out point:
1414
+ // its output IS the sequence the ForEach iterates. Its
1415
+ // backbone output node is dropped (replicas own the per-body
1416
+ // rendering), and its step becomes the "fork cap" whose title
1417
+ // labels the anchor→entry edge on the first body.
1418
+ //
1419
+ // Without such a preceding sequence cap, the source itself is
1420
+ // already a list (e.g. `media:pdf;list` source_spec) and the
1421
+ // ForEach iterates it directly.
1422
+ let seqProducerStepIdx = -1;
1423
+ let seqProducerStep = null;
1424
+ if (hasForeach && foreachStepIdx > 0) {
1425
+ const preStep = steps[foreachStepIdx - 1];
1426
+ if (Object.keys(preStep.step_type)[0] === 'Cap'
1427
+ && preStep.step_type.Cap.output_is_sequence === true) {
1428
+ seqProducerStepIdx = foreachStepIdx - 1;
1429
+ seqProducerStep = preStep;
1430
+ }
1431
+ }
1432
+
1433
+ // The anchor is the node BEFORE the sequence producer (or
1434
+ // before the ForEach if there is none). Every body iteration
1435
+ // shares this node as its starting point; per-body entries fan
1436
+ // out from here.
1437
+ let anchorNodeId;
1438
+ if (seqProducerStepIdx >= 0) {
1439
+ anchorNodeId = seqProducerStepIdx > 0
1440
+ ? `step_${seqProducerStepIdx - 1}`
1441
+ : 'input_slot';
1442
+ } else {
1443
+ anchorNodeId = foreachStepIdx > 0
1444
+ ? `step_${foreachStepIdx - 1}`
1445
+ : 'input_slot';
1446
+ }
1447
+
1448
+ // Strip nodes whose per-body replicas will replace them:
1449
+ // the sequence producer's backbone output (if any) AND every
1450
+ // body-interior cap. The foreach-entry edge is dropped
1451
+ // automatically because its source/target is in this set.
1452
+ const dropStepIds = new Set(bodyCapSteps.map(b => `step_${b.globalIndex}`));
1453
+ if (seqProducerStepIdx >= 0) {
1454
+ dropStepIds.add(`step_${seqProducerStepIdx}`);
1455
+ }
1456
+ let strandBuilt = stripRunBackboneReplicaNodes(strandBuiltCollapsed, dropStepIds);
1457
+
1458
+ // If there's a Collect, find the post-collect target in the
1459
+ // backbone — the node successful replicas merge into. This is
1460
+ // the first node reachable forward from the last body cap that
1461
+ // isn't itself a body cap. Walk the pre-strip backbone so we
1462
+ // can cross over the now-dropped body cap nodes.
1463
+ let collectTargetId = null;
1464
+ if (hasCollect && bodyCapSteps.length > 0) {
1465
+ const lastBodyStepId = `step_${bodyCapSteps[bodyCapSteps.length - 1].globalIndex}`;
1466
+ let cursor = lastBodyStepId;
1467
+ let guard = 64;
1468
+ while (guard-- > 0) {
1469
+ const out = strandBuiltCollapsed.edges.find(e => e.source === cursor);
1470
+ if (!out) break;
1471
+ cursor = out.target;
1472
+ if (!dropStepIds.has(cursor)) {
1473
+ collectTargetId = cursor;
1474
+ break;
1475
+ }
1476
+ }
1477
+ // If the last-body-step is itself the merged terminal (no
1478
+ // outgoing edge), there's no separate collect target —
1479
+ // replicas simply don't merge and the terminal node stays
1480
+ // dropped.
1481
+ }
1038
1482
 
1039
1483
  const replicaNodes = [];
1040
1484
  const replicaEdges = [];
1485
+ let replicasBuiltCount = 0;
1486
+
1487
+ // Look up a display name for a media URN via the host-supplied
1488
+ // `media_display_names` map. Uses `MediaUrn.isEquivalent` for
1489
+ // semantic URN equality.
1490
+ const MediaUrn = requireHostDependency('MediaUrn');
1491
+ const mediaDisplayNames = data.media_display_names || {};
1492
+ const displayEntries = [];
1493
+ for (const [urn, display] of Object.entries(mediaDisplayNames)) {
1494
+ if (typeof display !== 'string' || display.length === 0) continue;
1495
+ try {
1496
+ displayEntries.push({ media: MediaUrn.fromString(urn), display });
1497
+ } catch (_) { /* ignore malformed keys */ }
1498
+ }
1499
+ function displayNameFor(canonicalUrn) {
1500
+ const candidate = MediaUrn.fromString(canonicalUrn);
1501
+ for (const entry of displayEntries) {
1502
+ if (candidate.isEquivalent(entry.media)) return entry.display;
1503
+ }
1504
+ return mediaNodeLabel(canonicalUrn);
1505
+ }
1506
+
1507
+ // The per-body "entry" node represents one item of the
1508
+ // sequence being iterated. Its URN is:
1509
+ // * the sequence producer cap's `to_spec` (if such a cap
1510
+ // precedes the ForEach) — this is what Disbind produces,
1511
+ // one-per-body.
1512
+ // * otherwise the ForEach step's own `to_spec` — used when
1513
+ // the source spec is already a list.
1514
+ // Its label defaults to the display name of that URN and is
1515
+ // overridden by the host's per-body `outcome.title` (e.g.
1516
+ // "page_3") when provided.
1517
+ const entryUrn = seqProducerStep
1518
+ ? canonicalMediaUrn(seqProducerStep.to_spec)
1519
+ : canonicalMediaUrn(steps[foreachStepIdx].to_spec);
1520
+ const entryDefaultLabel = displayNameFor(entryUrn);
1521
+
1522
+ // Label shown on the anchor → first-body-entry edge. When the
1523
+ // fan-out is caused by a sequence-producing cap, its title
1524
+ // (e.g. "Disbind PDF Into Pages") labels that edge. The label
1525
+ // appears on the FIRST body's fork edge only to avoid N copies
1526
+ // of the same title cluttering the fan.
1527
+ const forkEdgeTitle = seqProducerStep
1528
+ ? seqProducerStep.step_type.Cap.title
1529
+ : '';
1530
+ const forkEdgeFullUrn = seqProducerStep
1531
+ ? seqProducerStep.step_type.Cap.cap_urn
1532
+ : '';
1041
1533
 
1042
1534
  function buildBodyReplica(outcome) {
1043
1535
  const success = outcome.success;
@@ -1045,7 +1537,7 @@ function buildRunGraphData(data) {
1045
1537
  const edgeClass = success ? 'body-success' : 'body-failure';
1046
1538
  const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
1047
1539
 
1048
- // Trace end: failures stop at failed_cap. `CapUrn.isEquivalent`
1540
+ // Trace end: failures stop at `failed_cap`. `CapUrn.isEquivalent`
1049
1541
  // is used for the match — never string equality.
1050
1542
  let traceEnd = bodyCapSteps.length;
1051
1543
  if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
@@ -1059,13 +1551,48 @@ function buildRunGraphData(data) {
1059
1551
  }
1060
1552
  }
1061
1553
  }
1062
- if (traceEnd === 0) return;
1063
1554
 
1064
- let prevBodyNodeId = anchorNodeId;
1065
1555
  const bodyKey = `body-${outcome.body_index}`;
1066
1556
  const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
1067
1557
  ? outcome.title
1068
- : `body ${outcome.body_index}`;
1558
+ : entryDefaultLabel;
1559
+
1560
+ // Per-body entry node: one item from the iterated sequence.
1561
+ const entryNodeId = `${bodyKey}-entry`;
1562
+ replicaNodes.push({
1563
+ group: 'nodes',
1564
+ data: {
1565
+ id: entryNodeId,
1566
+ label: titleLabel,
1567
+ fullUrn: entryUrn,
1568
+ bodyIndex: outcome.body_index,
1569
+ bodyTitle: titleLabel,
1570
+ },
1571
+ classes: successClass,
1572
+ });
1573
+
1574
+ // Fan-out edge from the pre-foreach anchor to this body's
1575
+ // entry. The fork label (cap title) shows on the FIRST body
1576
+ // only to avoid N copies of the same title cluttering the
1577
+ // fan. The `title` tooltip always exposes the cap title.
1578
+ const isFirstReplica = replicasBuiltCount === 0;
1579
+ const forkLabel = (forkEdgeTitle && isFirstReplica) ? forkEdgeTitle : '';
1580
+ replicaEdges.push({
1581
+ group: 'edges',
1582
+ data: {
1583
+ id: `${bodyKey}-fork`,
1584
+ source: anchorNodeId,
1585
+ target: entryNodeId,
1586
+ label: forkLabel,
1587
+ title: forkEdgeTitle || `body ${outcome.body_index}`,
1588
+ fullUrn: forkEdgeFullUrn,
1589
+ color: `var(${colorVar})`,
1590
+ bodyIndex: outcome.body_index,
1591
+ },
1592
+ classes: edgeClass,
1593
+ });
1594
+
1595
+ let prevBodyNodeId = entryNodeId;
1069
1596
 
1070
1597
  for (let i = 0; i < traceEnd; i++) {
1071
1598
  const body = bodyCapSteps[i].step.step_type.Cap;
@@ -1075,7 +1602,7 @@ function buildRunGraphData(data) {
1075
1602
  group: 'nodes',
1076
1603
  data: {
1077
1604
  id: replicaNodeId,
1078
- label: mediaNodeLabel(targetCanonical),
1605
+ label: displayNameFor(targetCanonical),
1079
1606
  fullUrn: targetCanonical,
1080
1607
  bodyIndex: outcome.body_index,
1081
1608
  bodyTitle: titleLabel,
@@ -1088,7 +1615,11 @@ function buildRunGraphData(data) {
1088
1615
  id: `${bodyKey}-e-${i}`,
1089
1616
  source: prevBodyNodeId,
1090
1617
  target: replicaNodeId,
1091
- label: i === 0 ? body.title : '',
1618
+ // Replica edges carry no inline label the cap title is
1619
+ // identical across every visible replica and would pile
1620
+ // up as unreadable rotated text across the fan-out. The
1621
+ // hover tooltip exposes the title via `title`.
1622
+ label: '',
1092
1623
  title: body.title,
1093
1624
  fullUrn: body.cap_urn,
1094
1625
  color: `var(${colorVar})`,
@@ -1099,17 +1630,18 @@ function buildRunGraphData(data) {
1099
1630
  prevBodyNodeId = replicaNodeId;
1100
1631
  }
1101
1632
 
1102
- // Successful bodies merge their replica tail back into the Collect
1103
- // node (or `output` if the ForEach is unclosed) so the graph
1104
- // visibly fans in. Failed bodies do NOT merge — the trace
1105
- // terminates at the failed cap.
1106
- if (success) {
1633
+ // Successful bodies merge into the collect target ONLY when a
1634
+ // Collect closes the foreach body. Without a Collect (the
1635
+ // common "unclosed foreach" case in machfab realize_strand),
1636
+ // replicas terminate at their last body step — each body has
1637
+ // its own separate output, no shared merge.
1638
+ if (success && hasCollect && collectTargetId) {
1107
1639
  replicaEdges.push({
1108
1640
  group: 'edges',
1109
1641
  data: {
1110
- id: `${bodyKey}-merge`,
1642
+ id: `${bodyKey}-collect`,
1111
1643
  source: prevBodyNodeId,
1112
- target: mergeNodeId,
1644
+ target: collectTargetId,
1113
1645
  label: '',
1114
1646
  title: 'collect',
1115
1647
  fullUrn: '',
@@ -1119,10 +1651,11 @@ function buildRunGraphData(data) {
1119
1651
  classes: edgeClass,
1120
1652
  });
1121
1653
  }
1654
+
1655
+ replicasBuiltCount++;
1122
1656
  }
1123
1657
 
1124
- visibleSuccess.forEach((o) => buildBodyReplica(o));
1125
- visibleFailure.forEach((o) => buildBodyReplica(o));
1658
+ visibleOutcomes.forEach((o) => buildBodyReplica(o));
1126
1659
 
1127
1660
  // Build success and failure "show more" nodes when there are hidden
1128
1661
  // outcomes. Anchored at the ForEach node (or input_slot if none).
@@ -1200,7 +1733,11 @@ function buildRunGraphData(data) {
1200
1733
  }
1201
1734
 
1202
1735
  function runCytoscapeElements(built) {
1203
- const strandElements = strandCytoscapeElements(built.strandBuilt);
1736
+ // Run mode's `buildRunGraphData` already applied the strand
1737
+ // collapse to its backbone (and dropped body-interior caps
1738
+ // that are replaced by per-body replicas). Emit the backbone
1739
+ // verbatim — `{ collapse: false }` prevents a second pass.
1740
+ const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
1204
1741
  return strandElements
1205
1742
  .concat(built.replicaNodes)
1206
1743
  .concat(built.showMoreNodes)
@@ -1209,67 +1746,150 @@ function runCytoscapeElements(built) {
1209
1746
 
1210
1747
  // --------- Machine mode builder ---------------------------------------------
1211
1748
 
1212
- function buildMachineGraphData(data) {
1213
- validateMachinePayload(data);
1214
- const nodes = [];
1215
- const edges = [];
1216
- let capEdgeIdx = 0;
1749
+ // The notation analyzer emits a bipartite graph where each cap
1750
+ // application is a 3-element chain:
1751
+ //
1752
+ // data_node [argument edge] → cap_node → [argument edge] → data_node
1753
+ //
1754
+ // The render collapses that chain into a single labeled data→data
1755
+ // edge carrying the cap's name (and cardinality marker derived
1756
+ // from the source/target data nodes' `is_sequence` flags). Cap
1757
+ // nodes and argument edges are dropped. The result is a clean
1758
+ // abstract-machine view that matches strand mode's rendering
1759
+ // style: one edge per cap application, labeled with the cap
1760
+ // title, with cardinality markers where relevant.
1761
+ function buildEditorGraphData(data) {
1762
+ validateEditorGraphPayload(data);
1763
+
1764
+ // Index elements by kind for quick lookup.
1765
+ const dataNodes = new Map(); // graph_id → element
1766
+ const capNodes = new Map(); // graph_id → element
1767
+ const argEdges = []; // list of edge elements
1217
1768
  for (const el of data.elements) {
1218
1769
  if (el.kind === 'node') {
1219
- nodes.push({
1220
- group: 'nodes',
1221
- data: {
1222
- id: el.graph_id,
1223
- label: el.label || '',
1224
- fullUrn: el.detail || el.label || '',
1225
- tokenId: el.token_id || '',
1226
- kind: 'node',
1227
- },
1228
- classes: 'machine-node',
1229
- });
1770
+ dataNodes.set(el.graph_id, el);
1230
1771
  } else if (el.kind === 'cap') {
1231
- nodes.push({
1232
- group: 'nodes',
1233
- data: {
1234
- id: el.graph_id,
1235
- label: el.label || '',
1236
- fullUrn: el.detail || el.label || '',
1237
- tokenId: el.token_id || '',
1238
- kind: 'cap',
1239
- },
1240
- classes: 'machine-cap' + (el.is_loop ? ' machine-loop' : ''),
1241
- });
1772
+ capNodes.set(el.graph_id, el);
1242
1773
  } else if (el.kind === 'edge') {
1243
- edges.push({
1244
- group: 'edges',
1245
- data: {
1246
- id: el.graph_id,
1247
- source: el.source_graph_id,
1248
- target: el.target_graph_id,
1249
- label: el.label || '',
1250
- title: el.label || '',
1251
- fullUrn: el.detail || '',
1252
- tokenId: el.token_id || '',
1253
- color: el.is_loop
1254
- ? 'var(--graph-node-border-highlighted)'
1255
- : edgeHueColor(capEdgeIdx),
1256
- },
1257
- classes: 'machine-edge' + (el.is_loop ? ' machine-loop' : ''),
1258
- });
1259
- capEdgeIdx++;
1774
+ argEdges.push(el);
1775
+ }
1776
+ }
1777
+
1778
+ // For each cap, identify its incoming and outgoing argument
1779
+ // edges. Incoming edges connect a data-slot source → this cap.
1780
+ // Outgoing edges connect this cap → a data-slot target.
1781
+ const capIncoming = new Map(); // capId → [argEdge, ...]
1782
+ const capOutgoing = new Map(); // capId → [argEdge, ...]
1783
+ for (const e of argEdges) {
1784
+ if (capNodes.has(e.target_graph_id)) {
1785
+ if (!capIncoming.has(e.target_graph_id)) capIncoming.set(e.target_graph_id, []);
1786
+ capIncoming.get(e.target_graph_id).push(e);
1787
+ }
1788
+ if (capNodes.has(e.source_graph_id)) {
1789
+ if (!capOutgoing.has(e.source_graph_id)) capOutgoing.set(e.source_graph_id, []);
1790
+ capOutgoing.get(e.source_graph_id).push(e);
1260
1791
  }
1261
1792
  }
1793
+
1794
+ const nodes = [];
1795
+ const edges = [];
1796
+
1797
+ // Emit the data slot nodes verbatim.
1798
+ for (const el of dataNodes.values()) {
1799
+ nodes.push({
1800
+ group: 'nodes',
1801
+ data: {
1802
+ id: el.graph_id,
1803
+ label: el.label || '',
1804
+ fullUrn: el.detail || el.label || '',
1805
+ tokenId: el.token_id || '',
1806
+ kind: 'node',
1807
+ isSequence: el.is_sequence === true,
1808
+ },
1809
+ classes: 'machine-node',
1810
+ });
1811
+ }
1812
+
1813
+ // Collapse each cap into one labeled edge per (input, output)
1814
+ // data-slot pair. For a simple single-input single-output cap
1815
+ // that's one edge. Multi-arg caps emit one edge per combination,
1816
+ // preserving the argument structure while still using a single
1817
+ // visual target per cap application.
1818
+ let capEdgeIdx = 0;
1819
+ for (const [capId, capEl] of capNodes) {
1820
+ const incoming = capIncoming.get(capId) || [];
1821
+ const outgoing = capOutgoing.get(capId) || [];
1822
+
1823
+ // Degenerate cases: a cap with no incoming or no outgoing
1824
+ // argument edges (e.g. partially-written notation). Render
1825
+ // nothing for it — the user sees a missing edge where they
1826
+ // need to complete the notation.
1827
+ if (incoming.length === 0 || outgoing.length === 0) continue;
1828
+
1829
+ // Cardinality of the cap's visual edge comes from the
1830
+ // source-side and target-side data-slot `is_sequence` flags.
1831
+ // When a cap has multiple inputs/outputs, use the MOST
1832
+ // restrictive reading (any seq input → `is_sequence=true` on
1833
+ // that side). This mirrors how cardinalityLabel collapses
1834
+ // multi-arg caps in the strand/browse builders.
1835
+ const inputIsSequence = incoming.some(e => {
1836
+ const src = dataNodes.get(e.source_graph_id);
1837
+ return src && src.is_sequence === true;
1838
+ });
1839
+ const outputIsSequence = outgoing.some(e => {
1840
+ const tgt = dataNodes.get(e.target_graph_id);
1841
+ return tgt && tgt.is_sequence === true;
1842
+ });
1843
+ const cardinality = cardinalityLabel(inputIsSequence, outputIsSequence);
1844
+ const capTitle = capEl.label || '';
1845
+ const label = cardinality === '1\u21921'
1846
+ ? capTitle
1847
+ : `${capTitle} (${cardinality})`;
1848
+
1849
+ const color = capEl.is_loop
1850
+ ? 'var(--graph-loop-color)'
1851
+ : edgeHueColor(capEdgeIdx);
1852
+ const baseClasses = capEl.is_loop ? 'machine-edge machine-loop' : 'machine-edge';
1853
+
1854
+ // Cartesian over incoming × outgoing. For the common case
1855
+ // of one incoming + one outgoing, that's exactly one emitted
1856
+ // edge.
1857
+ for (const inEdge of incoming) {
1858
+ for (const outEdge of outgoing) {
1859
+ edges.push({
1860
+ group: 'edges',
1861
+ data: {
1862
+ id: `${capId}-${inEdge.graph_id}-${outEdge.graph_id}`,
1863
+ source: inEdge.source_graph_id,
1864
+ target: outEdge.target_graph_id,
1865
+ label,
1866
+ title: capTitle,
1867
+ fullUrn: capEl.linked_cap_urn || capEl.detail || '',
1868
+ // The cap node's token id is what the editor uses
1869
+ // for cross-highlighting; prefer that over the arg
1870
+ // edge token ids so clicking the rendered edge
1871
+ // selects the cap in the source text.
1872
+ tokenId: capEl.token_id || '',
1873
+ color: color,
1874
+ },
1875
+ classes: baseClasses,
1876
+ });
1877
+ }
1878
+ }
1879
+ capEdgeIdx++;
1880
+ }
1881
+
1262
1882
  return { nodes, edges };
1263
1883
  }
1264
1884
 
1265
- function machineCytoscapeElements(built) {
1885
+ function editorGraphCytoscapeElements(built) {
1266
1886
  return built.nodes.concat(built.edges);
1267
1887
  }
1268
1888
 
1269
- // A cheap signature for machine-mode inputs. The editor streams updates
1270
- // on every keystroke; we skip the expensive rebuild when the element
1271
- // shape is unchanged.
1272
- function machineGraphSignature(data) {
1889
+ // A cheap signature for editor-graph mode inputs. The editor streams
1890
+ // updates on every keystroke; we skip the expensive rebuild when the
1891
+ // element shape is unchanged.
1892
+ function editorGraphSignature(data) {
1273
1893
  if (!data || !Array.isArray(data.elements)) return '';
1274
1894
  const parts = [];
1275
1895
  for (const el of data.elements) {
@@ -1278,6 +1898,110 @@ function machineGraphSignature(data) {
1278
1898
  return parts.join(';');
1279
1899
  }
1280
1900
 
1901
+ // `buildResolvedMachineGraphData` consumes the canonical
1902
+ // `Machine::to_render_payload_json` shape and produces the cytoscape
1903
+ // elements list directly. Each strand contributes its own nodes and
1904
+ // edges to the same graph; cytoscape lays disconnected components
1905
+ // side by side.
1906
+ //
1907
+ // Node ids and edge aliases are globally unique across all strands
1908
+ // (the Rust serializer assigns them from a single global counter),
1909
+ // so we can pass them straight through to cytoscape without name
1910
+ // collisions.
1911
+ //
1912
+ // Each cap edge is rendered as one cytoscape edge per
1913
+ // (assignment.source_node, target_node) pair. The common
1914
+ // single-input cap produces exactly one rendered edge; multi-input
1915
+ // (fan-in) caps produce one edge per source-arg slot, all sharing
1916
+ // the cap title and color so they read as a single fan-in.
1917
+ function buildResolvedMachineGraphData(data) {
1918
+ validateResolvedMachinePayload(data);
1919
+
1920
+ const nodes = [];
1921
+ const edges = [];
1922
+ const seenNodeIds = new Set();
1923
+ let edgeCounter = 0;
1924
+
1925
+ data.strands.forEach((strand, strandIdx) => {
1926
+ const inputAnchorSet = new Set(strand.input_anchor_nodes);
1927
+ const outputAnchorSet = new Set(strand.output_anchor_nodes);
1928
+
1929
+ for (const node of strand.nodes) {
1930
+ // Each node id is unique across the whole machine; the
1931
+ // Rust side guarantees this via its global counter. If a
1932
+ // duplicate appears it indicates a malformed payload, so
1933
+ // fail hard rather than silently dropping.
1934
+ if (seenNodeIds.has(node.id)) {
1935
+ throw new Error(
1936
+ `CapGraphRenderer machine mode: duplicate node id "${node.id}" in strand ${strandIdx}`
1937
+ );
1938
+ }
1939
+ seenNodeIds.add(node.id);
1940
+
1941
+ const nodeClasses = ['machine-node'];
1942
+ if (inputAnchorSet.has(node.id)) nodeClasses.push('strand-source');
1943
+ if (outputAnchorSet.has(node.id)) nodeClasses.push('strand-target');
1944
+
1945
+ nodes.push({
1946
+ group: 'nodes',
1947
+ data: {
1948
+ id: node.id,
1949
+ label: mediaNodeLabel(canonicalMediaUrn(node.urn)),
1950
+ fullUrn: node.urn,
1951
+ strandIndex: strandIdx,
1952
+ },
1953
+ classes: nodeClasses.join(' '),
1954
+ });
1955
+ }
1956
+
1957
+ for (const edge of strand.edges) {
1958
+ const cardinality = edge.is_loop ? 'n\u21921' : '1\u21921';
1959
+ const capUrn = edge.cap_urn;
1960
+ // Title falls back to the canonical cap URN. The host can
1961
+ // pre-resolve a friendlier title via `step_titles` on the
1962
+ // proto message and use it elsewhere in the UI; the graph
1963
+ // renderer is intentionally registry-free.
1964
+ const capTitle = capUrn;
1965
+ const label = cardinality === '1\u21921'
1966
+ ? edge.alias
1967
+ : `${edge.alias} (${cardinality})`;
1968
+
1969
+ const color = edge.is_loop
1970
+ ? 'var(--graph-loop-color)'
1971
+ : edgeHueColor(edgeCounter);
1972
+ const baseClasses = edge.is_loop
1973
+ ? 'machine-edge machine-loop'
1974
+ : 'machine-edge';
1975
+
1976
+ for (const binding of edge.assignment) {
1977
+ edges.push({
1978
+ group: 'edges',
1979
+ data: {
1980
+ id: `machine-edge-${edgeCounter}`,
1981
+ source: binding.source_node,
1982
+ target: edge.target_node,
1983
+ label,
1984
+ title: `${capTitle} (${binding.cap_arg_media_urn})`,
1985
+ fullUrn: capUrn,
1986
+ color,
1987
+ strandIndex: strandIdx,
1988
+ capArgMediaUrn: binding.cap_arg_media_urn,
1989
+ isLoop: edge.is_loop,
1990
+ },
1991
+ classes: baseClasses,
1992
+ });
1993
+ edgeCounter++;
1994
+ }
1995
+ }
1996
+ });
1997
+
1998
+ return { nodes, edges };
1999
+ }
2000
+
2001
+ function resolvedMachineCytoscapeElements(built) {
2002
+ return built.nodes.concat(built.edges);
2003
+ }
2004
+
1281
2005
  // =============================================================================
1282
2006
  // Renderer class.
1283
2007
  // =============================================================================
@@ -1291,9 +2015,9 @@ class CapGraphRenderer {
1291
2015
  throw new Error('CapGraphRenderer: options must be an object');
1292
2016
  }
1293
2017
  const mode = options.mode;
1294
- if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine') {
2018
+ if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine' && mode !== 'editor-graph') {
1295
2019
  throw new Error(
1296
- `CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine" (got ${JSON.stringify(mode)})`
2020
+ `CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine", "editor-graph" (got ${JSON.stringify(mode)})`
1297
2021
  );
1298
2022
  }
1299
2023
 
@@ -1357,8 +2081,12 @@ class CapGraphRenderer {
1357
2081
  this._strandBuilt = null;
1358
2082
  this._runBuilt = null;
1359
2083
 
1360
- // Machine state.
1361
- this._machineSignature = null;
2084
+ // Editor-graph state (Monaco machine-notation editor live view).
2085
+ this._editorGraphSignature = null;
2086
+ this._editorGraphBuilt = null;
2087
+
2088
+ // Resolved-machine state (canonical Machine render payload from
2089
+ // Rust `Machine::to_render_payload_json`).
1362
2090
  this._machineBuilt = null;
1363
2091
 
1364
2092
  // Theme observer.
@@ -1410,15 +2138,19 @@ class CapGraphRenderer {
1410
2138
  this._runBuilt = buildRunGraphData(data);
1411
2139
  return this;
1412
2140
  }
1413
- if (this.mode === 'machine') {
1414
- const signature = machineGraphSignature(data);
1415
- if (signature === this._machineSignature && this.cy) {
2141
+ if (this.mode === 'editor-graph') {
2142
+ const signature = editorGraphSignature(data);
2143
+ if (signature === this._editorGraphSignature && this.cy) {
1416
2144
  // Same shape — restyle for theme changes and return.
1417
2145
  this.cy.style(buildStylesheet());
1418
2146
  return this;
1419
2147
  }
1420
- this._machineSignature = signature;
1421
- this._machineBuilt = buildMachineGraphData(data);
2148
+ this._editorGraphSignature = signature;
2149
+ this._editorGraphBuilt = buildEditorGraphData(data);
2150
+ return this;
2151
+ }
2152
+ if (this.mode === 'machine') {
2153
+ this._machineBuilt = buildResolvedMachineGraphData(data);
1422
2154
  return this;
1423
2155
  }
1424
2156
  throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
@@ -1486,7 +2218,7 @@ class CapGraphRenderer {
1486
2218
  maxZoom: 10,
1487
2219
  wheelSensitivity: 0.3,
1488
2220
  boxSelectionEnabled: false,
1489
- autounselectify: this.mode === 'machine',
2221
+ autounselectify: this.mode === 'editor-graph' || this.mode === 'machine',
1490
2222
  });
1491
2223
 
1492
2224
  const resizeAndRefit = () => {
@@ -1519,9 +2251,13 @@ class CapGraphRenderer {
1519
2251
  if (!this._runBuilt) return [];
1520
2252
  return runCytoscapeElements(this._runBuilt);
1521
2253
  }
2254
+ if (this.mode === 'editor-graph') {
2255
+ if (!this._editorGraphBuilt) return [];
2256
+ return editorGraphCytoscapeElements(this._editorGraphBuilt);
2257
+ }
1522
2258
  if (this.mode === 'machine') {
1523
2259
  if (!this._machineBuilt) return [];
1524
- return machineCytoscapeElements(this._machineBuilt);
2260
+ return resolvedMachineCytoscapeElements(this._machineBuilt);
1525
2261
  }
1526
2262
  throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
1527
2263
  }
@@ -1782,12 +2518,13 @@ class CapGraphRenderer {
1782
2518
  }
1783
2519
 
1784
2520
  // ===========================================================================
1785
- // Machine mode API — used by the editor for cross-highlight.
2521
+ // Editor-graph mode API — used by the notation editor for
2522
+ // cross-highlight between source-text spans and graph elements.
1786
2523
  // ===========================================================================
1787
2524
 
1788
- applyMachineActiveTokenIds(tokenIds) {
1789
- if (this.mode !== 'machine') {
1790
- throw new Error(`CapGraphRenderer: applyMachineActiveTokenIds is only valid in machine mode (current: ${this.mode})`);
2525
+ applyEditorGraphActiveTokenIds(tokenIds) {
2526
+ if (this.mode !== 'editor-graph') {
2527
+ throw new Error(`CapGraphRenderer: applyEditorGraphActiveTokenIds is only valid in editor-graph mode (current: ${this.mode})`);
1791
2528
  }
1792
2529
  if (!this.cy) return;
1793
2530
  const wanted = new Set(tokenIds || []);
@@ -2103,12 +2840,15 @@ if (typeof module !== 'undefined' && module.exports) {
2103
2840
  mediaNodeLabel,
2104
2841
  buildBrowseGraphData,
2105
2842
  buildStrandGraphData,
2843
+ collapseStrandShapeTransitions,
2106
2844
  buildRunGraphData,
2107
- buildMachineGraphData,
2845
+ buildEditorGraphData,
2846
+ buildResolvedMachineGraphData,
2108
2847
  classifyStrandCapSteps,
2109
2848
  validateStrandPayload,
2110
2849
  validateRunPayload,
2111
- validateMachinePayload,
2850
+ validateEditorGraphPayload,
2851
+ validateResolvedMachinePayload,
2112
2852
  validateStrandStep,
2113
2853
  validateBodyOutcome,
2114
2854
  };