capdag 0.109.248 → 0.119.263

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.
@@ -271,7 +271,11 @@ function buildStylesheet() {
271
271
  {
272
272
  selector: 'node.show-more',
273
273
  style: {
274
- 'background-color': getCssVar('--graph-bg'),
274
+ // Use the normal node fill; the dashed border is what
275
+ // distinguishes a show-more node from a regular cap.
276
+ // The renderer never reads `--graph-bg` — the graph
277
+ // canvas background is entirely the host's concern.
278
+ 'background-color': nodeBg,
275
279
  'border-style': 'dashed',
276
280
  'border-width': '2px',
277
281
  'border-color': nodeBorderHighlighted,
@@ -739,7 +743,8 @@ function buildStrandGraphData(data) {
739
743
  });
740
744
  }
741
745
  let edgeCounter = 0;
742
- function addEdge(source, target, label, title, fullUrn, edgeClass) {
746
+ function addEdge(source, target, label, title, fullUrn, edgeClass, meta) {
747
+ const m = meta || {};
743
748
  edges.push({
744
749
  id: `strand-edge-${edgeCounter}`,
745
750
  source,
@@ -749,9 +754,32 @@ function buildStrandGraphData(data) {
749
754
  fullUrn: fullUrn || '',
750
755
  edgeClass: edgeClass || '',
751
756
  color: edgeHueColor(edgeCounter),
757
+ // `foreachEntry` flags a cap edge as the first cap entering a
758
+ // ForEach body (the "phantom direct edge" in plan builder's
759
+ // terminology). Render-time collapse uses this to relabel the
760
+ // edge with the cap title + (1→n) marker. Defaults to false.
761
+ foreachEntry: m.foreachEntry === true,
762
+ // `singleCapClosedBody` flags a foreach-entry cap edge whose
763
+ // body is closed by a Collect AND contains exactly one cap.
764
+ // Such a cap is both the body entry and the body exit; the
765
+ // render labels the edge with (n→n) to combine iterate+collect.
766
+ singleCapClosedBody: m.singleCapClosedBody === true,
752
767
  });
753
768
  edgeCounter++;
754
769
  }
770
+ // Find the most recently added cap edge whose target is `nodeId`
771
+ // and stamp it with `singleCapClosedBody=true`. Used by the Collect
772
+ // handler when a closed body has exactly one cap (bodyEntry === bodyExit).
773
+ function markSingleCapClosedBody(nodeId) {
774
+ if (!nodeId) return;
775
+ for (let idx = edges.length - 1; idx >= 0; idx--) {
776
+ const e = edges[idx];
777
+ if (e.edgeClass === 'strand-cap-edge' && e.target === nodeId) {
778
+ e.singleCapClosedBody = true;
779
+ return;
780
+ }
781
+ }
782
+ }
755
783
 
756
784
  // Entry node — the strand's source media spec.
757
785
  const inputSlotId = 'input_slot';
@@ -768,13 +796,14 @@ function buildStrandGraphData(data) {
768
796
  let bodyExit = null;
769
797
 
770
798
  // Finalize an outer ForEach body when a nested ForEach starts before
771
- // the outer's Collect. Mirrors plan_builder.rs:238-289.
799
+ // the outer's Collect. Mirrors plan_builder.rs:238-289. The render
800
+ // collapse will later drop the ForEach node and synthesize the
801
+ // bridging edges, so we only need to emit the plan-builder
802
+ // topology here.
772
803
  function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
773
804
  const outerForEachInput = outerForEach.index === 0
774
805
  ? inputSlotId
775
806
  : `step_${outerForEach.index - 1}`;
776
- // Create the ForEach node + direct edge from its input + iteration
777
- // edge into the body's first cap.
778
807
  addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
779
808
  addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
780
809
  addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
@@ -790,12 +819,27 @@ function buildStrandGraphData(data) {
790
819
  const toCanonical = canonicalMediaUrn(step.to_spec);
791
820
  addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
792
821
 
822
+ // The first cap inside a ForEach body is the "foreach entry"
823
+ // — its incoming edge crosses the foreach boundary. Strand
824
+ // mode's render collapse relabels that edge with a (1→n)
825
+ // cardinality marker regardless of the cap's own sequence
826
+ // flags, because visually the transition IS the foreach.
827
+ const isForeachEntry = insideForEachBody !== null && bodyEntry === null;
828
+
793
829
  let label = body.title;
794
830
  const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
795
831
  if (cardinality !== '1\u21921') {
796
832
  label = `${label} (${cardinality})`;
797
833
  }
798
- addEdge(prevNodeId, nodeId, label, body.title, body.cap_urn, 'strand-cap-edge');
834
+ addEdge(
835
+ prevNodeId,
836
+ nodeId,
837
+ label,
838
+ body.title,
839
+ body.cap_urn,
840
+ 'strand-cap-edge',
841
+ { foreachEntry: isForeachEntry }
842
+ );
799
843
 
800
844
  if (insideForEachBody !== null) {
801
845
  if (bodyEntry === null) bodyEntry = nodeId;
@@ -848,13 +892,26 @@ function buildStrandGraphData(data) {
848
892
  addNode(nodeId, 'collect', '', 'strand-collect');
849
893
  addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
850
894
 
895
+ // Single-cap closed body: the single cap serves as both the
896
+ // body entry and the body exit. The render collapse uses
897
+ // this flag to pick the (n→n) combined cardinality marker
898
+ // for the entry edge and emit a plain unlabeled connector
899
+ // for the synthesized exit edge.
900
+ if (bodyEntry !== null && bodyEntry === bodyExit) {
901
+ markSingleCapClosedBody(bodyEntry);
902
+ }
903
+
851
904
  insideForEachBody = null;
852
905
  bodyEntry = null;
853
906
  bodyExit = null;
854
907
  prevNodeId = nodeId;
855
908
  } else {
856
909
  // Standalone Collect — scalar → list-of-one. Mirrors
857
- // plan_builder.rs:333-355.
910
+ // plan_builder.rs:333-355. There is no enclosing foreach
911
+ // body, so the preceding cap is NOT flagged as a
912
+ // foreach-exit; the render-time collapse will synthesize a
913
+ // plain "collect" marker on the synthesized edge replacing
914
+ // the dropped Collect node.
858
915
  addNode(nodeId, 'collect', '', 'strand-collect');
859
916
  addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
860
917
  prevNodeId = nodeId;
@@ -866,6 +923,10 @@ function buildStrandGraphData(data) {
866
923
  });
867
924
 
868
925
  // Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
926
+ // An unclosed ForEach with a body is NOT marked as `singleCapClosedBody`
927
+ // because there's no Collect closing it — the body "fans out" but
928
+ // never converges. The body entry still gets (1→n) on its incoming
929
+ // edge via the foreachEntry flag set in the Cap handler.
869
930
  if (insideForEachBody !== null) {
870
931
  const outer = insideForEachBody;
871
932
  const hasBodyEntry = bodyEntry !== null;
@@ -892,11 +953,264 @@ function buildStrandGraphData(data) {
892
953
  addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
893
954
  addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
894
955
 
956
+ // Return the raw plan-builder topology. Strand mode collapses
957
+ // ForEach/Collect nodes into edge labels at render time (see
958
+ // `strandCytoscapeElements`); run mode keeps them as explicit
959
+ // nodes because body replicas anchor at the ForEach/Collect
960
+ // junctions.
895
961
  return { nodes, edges, sourceSpec, targetSpec };
896
962
  }
897
963
 
898
- function strandCytoscapeElements(built) {
899
- const nodeElements = built.nodes.map(node => ({
964
+ // Transform the plan-builder strand topology into the render shape
965
+ // strand mode actually displays. Pure function; does NOT mutate the
966
+ // input. Run mode bypasses this transform and consumes the raw
967
+ // topology directly (see `runCytoscapeElements`).
968
+ //
969
+ // The display rules (per user spec):
970
+ //
971
+ // 1. ForEach and Collect are NOT rendered as nodes. They're
972
+ // execution-layer concepts; the visible semantic is carried on
973
+ // the surrounding cap edges.
974
+ //
975
+ // 2. The first cap edge entering a ForEach body is relabeled to
976
+ // `<cap_title> (1→n)`. The builder flags those edges with
977
+ // `foreachEntry: true`. The (1→n) overrides whatever cardinality
978
+ // the cap's own `input_is_sequence`/`output_is_sequence` would
979
+ // produce, because visually the transition is the foreach.
980
+ //
981
+ // 3. The last cap edge exiting a ForEach body is replaced by a
982
+ // synthesized bridging edge labeled `<cap_title> (n→1)` — the
983
+ // symmetric counterpart of the foreach-entry (1→n) label.
984
+ // For a single-cap body (same cap is entry and exit) the flag
985
+ // `singleCapClosedBody` collapses both markers into a single
986
+ // `(n→n)` label on the entry edge, and the exit side becomes
987
+ // a plain unlabeled connector.
988
+ //
989
+ // 4. Standalone Collect (not wrapping a ForEach) synthesizes a
990
+ // plain edge labeled `"collect"` — the Collect is rendered
991
+ // as a transition, not the preceding cap's own label.
992
+ //
993
+ // 4. If the last cap step's `to_spec` is semantically equivalent
994
+ // to the strand's `target_spec` (via MediaUrn.isEquivalent),
995
+ // the separate `output` target node is dropped and the last
996
+ // cap edge lands on that merged endpoint. Removes the visible
997
+ // duplicate node.
998
+ function collapseStrandShapeTransitions(built) {
999
+ const MediaUrn = requireHostDependency('MediaUrn');
1000
+ const foreachCardinality = cardinalityLabel(false, true); // "1→n"
1001
+ const collectCardinality = cardinalityLabel(true, false); // "n→1"
1002
+
1003
+ // Index for lookups.
1004
+ const nodeById = new Map();
1005
+ for (const n of built.nodes) nodeById.set(n.id, n);
1006
+
1007
+ // Step 1: before dropping Collect nodes, synthesize the
1008
+ // bridging edge that replaces each dropped Collect. For a
1009
+ // Collect node C the plan builder produced:
1010
+ // body_exit → C (strand-collection, label="collect")
1011
+ // C → next (strand-cap-edge, label="")
1012
+ // The collapse drops C and its two touching edges. To preserve
1013
+ // the flow from body_exit to `next`, we synthesize a new edge
1014
+ // body_exit → next. Its label depends on the Collect's
1015
+ // context (determined by inspecting the body_exit cap's own
1016
+ // incoming edge):
1017
+ //
1018
+ // * Standalone Collect (no enclosing foreach; body_exit is a
1019
+ // plain cap with no foreach markers): synthesize a plain
1020
+ // edge labeled "collect" — the Collect itself is the
1021
+ // transition the user sees.
1022
+ //
1023
+ // * Closed-body Collect with a single-cap body (the body_exit
1024
+ // cap edge is flagged `singleCapClosedBody`): synthesize an
1025
+ // unlabeled connector. The single cap's own entry edge is
1026
+ // relabeled with (n→n) in step 2 and carries both the
1027
+ // iterate and collect semantics at once.
1028
+ //
1029
+ // * Closed-body Collect with a multi-cap body (body_exit cap
1030
+ // edge is a plain cap edge inside the body, NOT marked as
1031
+ // foreachEntry or singleCapClosedBody): label the synth
1032
+ // edge with the body-exit cap's title + (n→1) — the
1033
+ // symmetric counterpart of the foreach-entry (1→n) label.
1034
+ const synthesizedExitEdges = [];
1035
+ for (const node of built.nodes) {
1036
+ if (node.nodeClass !== 'strand-collect') continue;
1037
+ const incoming = built.edges.filter(e =>
1038
+ e.target === node.id && e.edgeClass === 'strand-collection');
1039
+ const outgoing = built.edges.filter(e =>
1040
+ e.source === node.id && e.edgeClass === 'strand-cap-edge');
1041
+ for (const inEdge of incoming) {
1042
+ for (const outEdge of outgoing) {
1043
+ const bodyExitNodeId = inEdge.source;
1044
+ const bodyExitCapEdge = built.edges.find(e =>
1045
+ e.edgeClass === 'strand-cap-edge' && e.target === bodyExitNodeId);
1046
+ // Is the Collect part of a closed foreach body? A Collect
1047
+ // is "closed" when the body_exit cap is reachable from the
1048
+ // same foreach that the Collect closes. We detect this by
1049
+ // checking whether the Collect's target node has any
1050
+ // incoming iteration edge (i.e. there's a strand-foreach
1051
+ // node whose outgoing iteration reaches the body_exit or
1052
+ // one of its upstream cap nodes). Simpler heuristic that
1053
+ // works for every shape we emit: the body_exit has an
1054
+ // ancestor that is a strand-foreach node.
1055
+ let hasEnclosingForeach = false;
1056
+ {
1057
+ const visited = new Set();
1058
+ const stack = [bodyExitNodeId];
1059
+ while (stack.length > 0) {
1060
+ const cur = stack.pop();
1061
+ if (visited.has(cur)) continue;
1062
+ visited.add(cur);
1063
+ const curNode = built.nodes.find(n => n.id === cur);
1064
+ if (curNode && curNode.nodeClass === 'strand-foreach') {
1065
+ hasEnclosingForeach = true;
1066
+ break;
1067
+ }
1068
+ for (const up of built.edges) {
1069
+ if (up.target === cur) stack.push(up.source);
1070
+ }
1071
+ }
1072
+ }
1073
+ let synthLabel;
1074
+ let synthTitle;
1075
+ let synthFullUrn;
1076
+ if (!hasEnclosingForeach) {
1077
+ // Standalone Collect.
1078
+ synthLabel = 'collect';
1079
+ synthTitle = 'collect';
1080
+ synthFullUrn = '';
1081
+ } else if (bodyExitCapEdge && bodyExitCapEdge.singleCapClosedBody) {
1082
+ // Single-cap closed body. The cap's own entry edge is
1083
+ // relabeled with (n→n) in step 2; the exit side is just
1084
+ // a connector with no duplicate label.
1085
+ synthLabel = '';
1086
+ synthTitle = '';
1087
+ synthFullUrn = '';
1088
+ } else {
1089
+ // Multi-cap closed body. The body_exit cap's own
1090
+ // incoming edge stays unmodified (carrying just the
1091
+ // cap title, no cardinality markers). The synthesized
1092
+ // exit edge carries the (n→1) collect marker.
1093
+ const title = bodyExitCapEdge ? bodyExitCapEdge.title : '';
1094
+ synthLabel = `${title} (${collectCardinality})`;
1095
+ synthTitle = title;
1096
+ synthFullUrn = bodyExitCapEdge ? bodyExitCapEdge.fullUrn : '';
1097
+ }
1098
+ synthesizedExitEdges.push({
1099
+ id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
1100
+ source: bodyExitNodeId,
1101
+ target: outEdge.target,
1102
+ label: synthLabel,
1103
+ title: synthTitle,
1104
+ fullUrn: synthFullUrn,
1105
+ edgeClass: 'strand-cap-edge',
1106
+ color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
1107
+ foreachEntry: false,
1108
+ singleCapClosedBody: false,
1109
+ });
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ // Drop all ForEach/Collect nodes and every edge that touches
1115
+ // them (direct, iteration, collection, and the trailing collect
1116
+ // cap-edge connector). The render never shows those nodes.
1117
+ const dropNodeIds = new Set();
1118
+ for (const node of built.nodes) {
1119
+ if (node.nodeClass === 'strand-foreach' || node.nodeClass === 'strand-collect') {
1120
+ dropNodeIds.add(node.id);
1121
+ }
1122
+ }
1123
+ let nodes = built.nodes.filter(n => !dropNodeIds.has(n.id));
1124
+ let edges = built.edges.filter(e =>
1125
+ !dropNodeIds.has(e.source) &&
1126
+ !dropNodeIds.has(e.target) &&
1127
+ e.edgeClass !== 'strand-iteration' &&
1128
+ e.edgeClass !== 'strand-collection');
1129
+ edges = edges.concat(synthesizedExitEdges);
1130
+
1131
+ // Step 2: relabel flagged foreach-entry cap edges with the
1132
+ // cap title + cardinality marker.
1133
+ //
1134
+ // When the body is a single cap (bodyEntry === bodyExit under a
1135
+ // closed Collect), the cap edge has `singleCapClosedBody: true`
1136
+ // and we use (n→n) — the outer sequence is iterated, the cap
1137
+ // runs per-item, and the result is collected back into a
1138
+ // sequence. Otherwise we use plain (1→n) for the foreach entry.
1139
+ const iterAndCollectCardinality = cardinalityLabel(true, true); // "n→n"
1140
+ edges = edges.map(e => {
1141
+ if (e.edgeClass !== 'strand-cap-edge') return e;
1142
+ if (e.foreachEntry && e.singleCapClosedBody) {
1143
+ return Object.assign({}, e, {
1144
+ label: `${e.title} (${iterAndCollectCardinality})`,
1145
+ });
1146
+ }
1147
+ if (e.foreachEntry) {
1148
+ return Object.assign({}, e, {
1149
+ label: `${e.title} (${foreachCardinality})`,
1150
+ });
1151
+ }
1152
+ return e;
1153
+ });
1154
+
1155
+ // Step 3: merge the trailing `step_N → output` edge when step_N
1156
+ // and output represent the same media URN. The strand builder
1157
+ // always emits a separate `output` node with a (possibly empty)
1158
+ // connector edge from the last prev; when the URNs coincide the
1159
+ // output is a visible duplicate.
1160
+ //
1161
+ // Find the `strand-target` node and look for a single incoming
1162
+ // edge from a `strand-cap` (or `strand-source`) node. Compare the
1163
+ // endpoints' `fullUrn` semantically.
1164
+ const targetNode = nodes.find(n => n.nodeClass === 'strand-target');
1165
+ if (targetNode) {
1166
+ const incomingToTarget = edges.filter(e => e.target === targetNode.id);
1167
+ if (incomingToTarget.length === 1) {
1168
+ const trailing = incomingToTarget[0];
1169
+ const upstreamNode = nodes.find(n => n.id === trailing.source);
1170
+ if (upstreamNode && upstreamNode.fullUrn && targetNode.fullUrn) {
1171
+ let equivalent = false;
1172
+ try {
1173
+ const a = MediaUrn.fromString(upstreamNode.fullUrn);
1174
+ const b = MediaUrn.fromString(targetNode.fullUrn);
1175
+ equivalent = a.isEquivalent(b);
1176
+ } catch (_) {
1177
+ equivalent = false;
1178
+ }
1179
+ // Merge only if the trailing edge is the unadorned connector
1180
+ // (empty label). A labeled last-cap edge carries meaningful
1181
+ // info and must not be collapsed away.
1182
+ if (equivalent && (!trailing.label || trailing.label.length === 0)) {
1183
+ // Drop the trailing connector edge and the target node.
1184
+ // The upstream node effectively becomes the target visually.
1185
+ // Rename its display label to the target's display to
1186
+ // preserve the user-configured media_display_names entry.
1187
+ edges = edges.filter(e => e.id !== trailing.id);
1188
+ nodes = nodes
1189
+ .filter(n => n.id !== targetNode.id)
1190
+ .map(n => n.id === upstreamNode.id
1191
+ ? Object.assign({}, n, { label: targetNode.label, nodeClass: 'strand-target' })
1192
+ : n);
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ return { nodes, edges };
1199
+ }
1200
+
1201
+ function strandCytoscapeElements(built, options) {
1202
+ // Strand mode presents ForEach and Collect as edge labels, not as
1203
+ // boxed nodes. Apply the cosmetic collapse right before emitting
1204
+ // cytoscape elements so the underlying plan-builder topology stays
1205
+ // intact for any callers that need it.
1206
+ //
1207
+ // Run mode opts out (`options.collapse === false`) because its body
1208
+ // replicas visually fan out from the ForEach node and merge at the
1209
+ // Collect node — those junctions must remain as explicit graph
1210
+ // nodes for the fan-in/fan-out to be visible.
1211
+ const shouldCollapse = !(options && options.collapse === false);
1212
+ const source = shouldCollapse ? collapseStrandShapeTransitions(built) : built;
1213
+ const nodeElements = source.nodes.map(node => ({
900
1214
  group: 'nodes',
901
1215
  data: {
902
1216
  id: node.id,
@@ -905,7 +1219,7 @@ function strandCytoscapeElements(built) {
905
1219
  },
906
1220
  classes: node.nodeClass || '',
907
1221
  }));
908
- const edgeElements = built.edges.map(edge => ({
1222
+ const edgeElements = source.edges.map(edge => ({
909
1223
  group: 'edges',
910
1224
  data: {
911
1225
  id: edge.id,
@@ -938,31 +1252,74 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
938
1252
  return -1;
939
1253
  }
940
1254
 
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) {
1255
+ // Remove body-interior cap nodes (caps strictly AFTER the first
1256
+ // body cap) from a COLLAPSED strand backbone so that per-body
1257
+ // replicas are the only visible path through the foreach body.
1258
+ // The FIRST body cap node is retained its incoming foreachEntry
1259
+ // edge is the backbone "fallback connector" that keeps the graph
1260
+ // connected when zero bodies succeed.
1261
+ //
1262
+ // Dropping interior body caps also drops their outgoing edges,
1263
+ // including any synthesized collect-exit edge that would have
1264
+ // connected the last body cap to the post-collect target. To
1265
+ // preserve connectivity, the function replaces those with a
1266
+ // plain bridging edge from the first body cap directly to the
1267
+ // post-collect target.
1268
+ function stripRunBackboneBodyInterior(built, steps, foreachStepIdx, collectStepIdx) {
1269
+ if (foreachStepIdx < 0) return built;
947
1270
  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();
1271
+
1272
+ const interiorStepIdxs = [];
951
1273
  for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
952
1274
  if (Object.keys(steps[i].step_type)[0] === 'Cap') {
953
- interiorIds.add(`step_${i}`);
1275
+ interiorStepIdxs.push(i);
1276
+ }
1277
+ }
1278
+ if (interiorStepIdxs.length <= 1) {
1279
+ // Zero or one body cap — no prototype chain to strip.
1280
+ return built;
1281
+ }
1282
+
1283
+ const firstBodyStepId = `step_${interiorStepIdxs[0]}`;
1284
+ const lastBodyStepId = `step_${interiorStepIdxs[interiorStepIdxs.length - 1]}`;
1285
+ const dropInteriorIds = new Set();
1286
+ for (let i = 1; i < interiorStepIdxs.length; i++) {
1287
+ dropInteriorIds.add(`step_${interiorStepIdxs[i]}`);
1288
+ }
1289
+
1290
+ // Find the post-body target: the node that the last body cap's
1291
+ // outgoing edge points at in the collapsed backbone. This is
1292
+ // usually `output` or (after the collect-exit synth) the merged
1293
+ // target node.
1294
+ const postBodyEdge = built.edges.find(e => e.source === lastBodyStepId);
1295
+ const postBodyTarget = postBodyEdge ? postBodyEdge.target : null;
1296
+
1297
+ const keptNodes = built.nodes.filter(n => !dropInteriorIds.has(n.id));
1298
+ let keptEdges = built.edges.filter(e =>
1299
+ !dropInteriorIds.has(e.source) && !dropInteriorIds.has(e.target));
1300
+
1301
+ // Bridge firstBody → postBodyTarget directly with a plain
1302
+ // unlabeled connector so the graph stays connected. Replicas
1303
+ // will overlay labeled chains alongside this connector.
1304
+ if (postBodyTarget && postBodyTarget !== firstBodyStepId) {
1305
+ const bridgeExists = keptEdges.some(e =>
1306
+ e.source === firstBodyStepId && e.target === postBodyTarget);
1307
+ if (!bridgeExists) {
1308
+ keptEdges = keptEdges.concat([{
1309
+ id: `run-bridge-${firstBodyStepId}-${postBodyTarget}`,
1310
+ source: firstBodyStepId,
1311
+ target: postBodyTarget,
1312
+ label: '',
1313
+ title: '',
1314
+ fullUrn: '',
1315
+ edgeClass: 'strand-cap-edge',
1316
+ color: postBodyEdge ? postBodyEdge.color : edgeHueColor(0),
1317
+ foreachEntry: false,
1318
+ singleCapClosedBody: false,
1319
+ }]);
954
1320
  }
955
1321
  }
956
- if (interiorIds.size === 0) return built;
957
-
958
- const keptNodes = built.nodes.filter(n => !interiorIds.has(n.id));
959
- const keptEdges = built.edges.filter(e =>
960
- !interiorIds.has(e.source) && !interiorIds.has(e.target));
961
1322
 
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.
966
1323
  return {
967
1324
  nodes: keptNodes,
968
1325
  edges: keptEdges,
@@ -974,20 +1331,19 @@ function stripBodyInteriorFromStrandBackbone(built, steps, foreachStepIdx, colle
974
1331
  function buildRunGraphData(data) {
975
1332
  validateRunPayload(data);
976
1333
 
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.
1334
+ // Build the raw strand topology and then apply the cosmetic
1335
+ // collapse run mode uses the SAME cleaned-up backbone that
1336
+ // strand mode uses (no ForEach/Collect nodes). Replicas are an
1337
+ // additional overlay on top of that collapsed backbone.
980
1338
  const strandInput = Object.assign({}, data.resolved_strand, {
981
1339
  media_display_names: data.media_display_names,
982
1340
  });
983
1341
  const strandBuiltRaw = buildStrandGraphData(strandInput);
1342
+ const strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
984
1343
 
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`.
1344
+ // Locate the ForEach/Collect span in the raw steps. Positional IDs
1345
+ // survive the collapse (node IDs are `step_${i}` from the builder),
1346
+ // so we can still identify which collapsed nodes are the body caps.
991
1347
  const steps = data.resolved_strand.steps;
992
1348
  let foreachStepIdx = -1;
993
1349
  let collectStepIdx = -1;
@@ -998,16 +1354,14 @@ function buildRunGraphData(data) {
998
1354
  }
999
1355
  const hasForeach = foreachStepIdx >= 0;
1000
1356
 
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;
1357
+ // Drop interior body-cap nodes (caps 2..N inside the body) from
1358
+ // the collapsed backbone so the prototype chain doesn't duplicate
1359
+ // the per-body replicas visually. The FIRST body cap is retained
1360
+ // as the body-entry node so the backbone's foreachEntry edge
1361
+ // (`<cap_title> (1→n)`) still has a landing point.
1362
+ let strandBuilt = hasForeach
1363
+ ? stripRunBackboneBodyInterior(strandBuiltCollapsed, steps, foreachStepIdx, collectStepIdx)
1364
+ : strandBuiltCollapsed;
1011
1365
 
1012
1366
  // Filter and bound the outcomes.
1013
1367
  const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
@@ -1029,12 +1383,82 @@ function buildRunGraphData(data) {
1029
1383
  }
1030
1384
  }
1031
1385
 
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';
1386
+ // Body replicas fan out from the node BEFORE the foreach (which,
1387
+ // in the collapsed backbone, is the source of the foreachEntry
1388
+ // edge). For a foreach at step index k, the pre-foreach node is
1389
+ // `step_${k-1}` when k > 0, or `input_slot` when k == 0. This
1390
+ // node survives the collapse as a regular `strand-cap` or
1391
+ // `strand-source` node.
1392
+ //
1393
+ // Replicas merge back at the node AFTER the body. After collapse,
1394
+ // this is the FIRST body cap node (`step_${bodyCapSteps[0]}`)
1395
+ // when the ForEach has a single-cap body, or the post-collect
1396
+ // node for multi-cap bodies. The post-collect node is whatever
1397
+ // the plan builder's Collect step connected to downstream, which
1398
+ // after collapse is either the next cap (`step_${collectStepIdx+1}`)
1399
+ // or the target/output node (possibly merged into the last body
1400
+ // cap if their URNs are equivalent).
1401
+ const anchorNodeId = hasForeach && foreachStepIdx > 0
1402
+ ? `step_${foreachStepIdx - 1}`
1403
+ : 'input_slot';
1404
+
1405
+ // Find the merge node by scanning the collapsed backbone for the
1406
+ // node the body reaches after the foreach body. Start from the
1407
+ // first body cap node and follow forward edges until we leave
1408
+ // the body. For a single-body-cap strand where the body cap IS
1409
+ // the merged target, `mergeNodeId` resolves to that node.
1410
+ let mergeNodeId;
1411
+ {
1412
+ // Find body cap step indices in the resolved strand so we can
1413
+ // identify which step IDs are "inside body" (should be skipped
1414
+ // when finding the merge target).
1415
+ const bodyInteriorSet = new Set();
1416
+ const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
1417
+ for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
1418
+ if (Object.keys(steps[i].step_type)[0] === 'Cap') {
1419
+ bodyInteriorSet.add(`step_${i}`);
1420
+ }
1421
+ }
1422
+ // Start from the first body cap and walk forward.
1423
+ const firstBodyCapIdx = (() => {
1424
+ for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
1425
+ if (Object.keys(steps[i].step_type)[0] === 'Cap') return i;
1426
+ }
1427
+ return -1;
1428
+ })();
1429
+ if (firstBodyCapIdx < 0) {
1430
+ mergeNodeId = 'output';
1431
+ } else {
1432
+ // Walk the collapsed strand's edges starting from the first
1433
+ // body cap node; the merge target is the first node reached
1434
+ // that isn't inside the body.
1435
+ let cursor = `step_${firstBodyCapIdx}`;
1436
+ let guard = 64;
1437
+ const collapsedNodeIds = new Set(strandBuilt.nodes.map(n => n.id));
1438
+ while (guard-- > 0) {
1439
+ // If the cursor is itself outside the body, we've found it.
1440
+ if (!bodyInteriorSet.has(cursor) && collapsedNodeIds.has(cursor)) {
1441
+ mergeNodeId = cursor;
1442
+ break;
1443
+ }
1444
+ // Otherwise follow the cursor's outgoing edge.
1445
+ const out = strandBuilt.edges.find(e => e.source === cursor);
1446
+ if (!out) {
1447
+ // Nothing to follow — the body cap IS the end of the
1448
+ // strand. Use the cursor as the merge point (it's
1449
+ // typically the merged target node).
1450
+ mergeNodeId = cursor;
1451
+ break;
1452
+ }
1453
+ cursor = out.target;
1454
+ }
1455
+ if (mergeNodeId === undefined) {
1456
+ // Guard exhausted — fall back to output. This is impossible
1457
+ // for any well-formed strand but keeps the runtime safe.
1458
+ mergeNodeId = 'output';
1459
+ }
1460
+ }
1461
+ }
1038
1462
 
1039
1463
  const replicaNodes = [];
1040
1464
  const replicaEdges = [];
@@ -1088,7 +1512,12 @@ function buildRunGraphData(data) {
1088
1512
  id: `${bodyKey}-e-${i}`,
1089
1513
  source: prevBodyNodeId,
1090
1514
  target: replicaNodeId,
1091
- label: i === 0 ? body.title : '',
1515
+ // Replica edges carry no inline label the cap title is
1516
+ // identical across every visible replica and would just
1517
+ // pile up as unreadable rotated text over the fan-out.
1518
+ // Hover tooltip exposes the title via `title`; the edge
1519
+ // color + the replica node identify the body.
1520
+ label: '',
1092
1521
  title: body.title,
1093
1522
  fullUrn: body.cap_urn,
1094
1523
  color: `var(${colorVar})`,
@@ -1124,6 +1553,44 @@ function buildRunGraphData(data) {
1124
1553
  visibleSuccess.forEach((o) => buildBodyReplica(o));
1125
1554
  visibleFailure.forEach((o) => buildBodyReplica(o));
1126
1555
 
1556
+ // Once any replicas are rendered (success or failure), the
1557
+ // backbone foreach-entry edge becomes a stale placeholder — the
1558
+ // replicas ARE the actual execution traces, so the user
1559
+ // shouldn't see a duplicate labeled "1→n" edge alongside them.
1560
+ // Drop the backbone foreach-entry edge whenever we emit
1561
+ // replicas, AND if there were zero successful replicas also
1562
+ // drop the orphaned merge node: nothing reached it, and
1563
+ // showing a disconnected node misleads the user into thinking
1564
+ // the target was reached.
1565
+ if (hasForeach && (visibleSuccess.length > 0 || visibleFailure.length > 0)) {
1566
+ const backboneForeachEntry = strandBuilt.edges.find(e =>
1567
+ e.edgeClass === 'strand-cap-edge' &&
1568
+ e.source === anchorNodeId &&
1569
+ e.foreachEntry === true);
1570
+ let newEdges = strandBuilt.edges;
1571
+ let newNodes = strandBuilt.nodes;
1572
+ if (backboneForeachEntry) {
1573
+ newEdges = newEdges.filter(e => e.id !== backboneForeachEntry.id);
1574
+ }
1575
+ // If no successful replica will merge into `mergeNodeId`, and
1576
+ // the merge node is now only reachable via the (just-dropped)
1577
+ // backbone edge, drop the merge node itself.
1578
+ if (visibleSuccess.length === 0 && mergeNodeId) {
1579
+ const stillIncoming = newEdges.some(e => e.target === mergeNodeId);
1580
+ const replicaIncoming = replicaEdges.some(e => e.data && e.data.target === mergeNodeId);
1581
+ if (!stillIncoming && !replicaIncoming) {
1582
+ newNodes = newNodes.filter(n => n.id !== mergeNodeId);
1583
+ newEdges = newEdges.filter(e => e.source !== mergeNodeId && e.target !== mergeNodeId);
1584
+ }
1585
+ }
1586
+ strandBuilt = {
1587
+ nodes: newNodes,
1588
+ edges: newEdges,
1589
+ sourceSpec: strandBuilt.sourceSpec,
1590
+ targetSpec: strandBuilt.targetSpec,
1591
+ };
1592
+ }
1593
+
1127
1594
  // Build success and failure "show more" nodes when there are hidden
1128
1595
  // outcomes. Anchored at the ForEach node (or input_slot if none).
1129
1596
  const showMoreNodes = [];
@@ -1200,7 +1667,10 @@ function buildRunGraphData(data) {
1200
1667
  }
1201
1668
 
1202
1669
  function runCytoscapeElements(built) {
1203
- const strandElements = strandCytoscapeElements(built.strandBuilt);
1670
+ // Do NOT collapse ForEach/Collect nodes in run mode — body replicas
1671
+ // anchor at the ForEach node (fan-out) and merge at the Collect
1672
+ // node (fan-in). Those junctions must stay visible.
1673
+ const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
1204
1674
  return strandElements
1205
1675
  .concat(built.replicaNodes)
1206
1676
  .concat(built.showMoreNodes)
@@ -2103,6 +2573,7 @@ if (typeof module !== 'undefined' && module.exports) {
2103
2573
  mediaNodeLabel,
2104
2574
  buildBrowseGraphData,
2105
2575
  buildStrandGraphData,
2576
+ collapseStrandShapeTransitions,
2106
2577
  buildRunGraphData,
2107
2578
  buildMachineGraphData,
2108
2579
  classifyStrandCapSteps,
package/capdag.test.js CHANGED
@@ -3755,6 +3755,7 @@ const {
3755
3755
  canonicalMediaUrn: rendererCanonicalMediaUrn,
3756
3756
  mediaNodeLabel: rendererMediaNodeLabel,
3757
3757
  buildStrandGraphData: rendererBuildStrandGraphData,
3758
+ collapseStrandShapeTransitions: rendererCollapseStrandShapeTransitions,
3758
3759
  buildRunGraphData: rendererBuildRunGraphData,
3759
3760
  buildMachineGraphData: rendererBuildMachineGraphData,
3760
3761
  classifyStrandCapSteps: rendererClassifyStrandCapSteps,
@@ -4204,6 +4205,269 @@ function testRenderer_buildStrandGraphData_nestedForEachThrows() {
4204
4205
  assert(threw, 'nested ForEach without outer body cap must throw');
4205
4206
  }
4206
4207
 
4208
+ function testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker() {
4209
+ // User spec: ForEach/Collect are NOT rendered as nodes. The
4210
+ // transition is labeled with the enclosed cap's title + a
4211
+ // cardinality marker. For a single-cap body the marker is n→n
4212
+ // (iterate + collect combined) because the same cap is both
4213
+ // the body entry and the body exit.
4214
+ //
4215
+ // Strand [ForEach, Cap(extract), Collect], source=pdf;list,
4216
+ // target=txt;list — target is NOT equivalent to cap's to_spec
4217
+ // media:txt so the output node is retained.
4218
+ //
4219
+ // Expected render shape: 3 nodes (input_slot, step_1, output),
4220
+ // with the entry edge labeled "extract (n→n)" and a plain
4221
+ // unlabeled connector to the output.
4222
+ const payload = {
4223
+ source_spec: 'media:pdf;list',
4224
+ target_spec: 'media:txt;list',
4225
+ steps: [
4226
+ makeForEachStep('media:pdf;list'),
4227
+ makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
4228
+ makeCollectStep('media:txt'),
4229
+ ],
4230
+ };
4231
+ const built = rendererBuildStrandGraphData(payload);
4232
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4233
+
4234
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4235
+ assertEqual(JSON.stringify(nodeIds),
4236
+ JSON.stringify(['input_slot', 'output', 'step_1']),
4237
+ 'collapse removes the ForEach and Collect nodes; the remaining nodes are source + cap + target');
4238
+
4239
+ // Exactly one edge from input_slot → step_1, carrying the cap
4240
+ // title + iterate+collect cardinality marker.
4241
+ const entryEdges = collapsed.edges.filter(e => e.source === 'input_slot' && e.target === 'step_1');
4242
+ assertEqual(entryEdges.length, 1,
4243
+ 'phantom duplicate cap edge must be gone — exactly one edge from source to cap');
4244
+ assertEqual(entryEdges[0].label, 'extract (n\u2192n)',
4245
+ 'single-cap-body edge is labeled "<cap_title> (n→n)"');
4246
+
4247
+ // The exit side is a plain unlabeled connector — the cap title
4248
+ // is already shown on the entry edge.
4249
+ const exitEdges = collapsed.edges.filter(e => e.source === 'step_1' && e.target === 'output');
4250
+ assertEqual(exitEdges.length, 1,
4251
+ 'there is exactly one exit edge step_1 → output');
4252
+ assertEqual(exitEdges[0].label, '',
4253
+ 'exit connector for a single-cap body is unlabeled (cap title already shown on entry edge)');
4254
+ }
4255
+
4256
+ function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
4257
+ // [Cap_a, ForEach, Cap_b] with no Collect, source=media:a,
4258
+ // target=media:c. Cap_b's to_spec is media:c which is
4259
+ // equivalent to target_spec, so the output node is merged
4260
+ // into step_2.
4261
+ //
4262
+ // Raw topology:
4263
+ // input_slot → step_0 (cap_a) — normal, not in any body
4264
+ // step_0 → step_2 (cap_b, foreachEntry=true — phantom under
4265
+ // plan builder, but the foreach body entry
4266
+ // under the render model)
4267
+ // step_0 → step_1 (foreach direct)
4268
+ // step_1 → step_2 (iteration)
4269
+ // step_2 → output (trailing connector, empty label)
4270
+ //
4271
+ // Collapse:
4272
+ // - step_1 (foreach) removed with its iteration edges.
4273
+ // - step_0 → step_2 relabeled "b (1→n)" via foreachEntry.
4274
+ // - step_2 → output merged because upstream.fullUrn equivalent
4275
+ // to target_spec (both media:c); step_2 takes the target
4276
+ // display label.
4277
+ //
4278
+ // Final: 3 nodes (input_slot, step_0, step_2), 2 edges.
4279
+ const payload = {
4280
+ source_spec: 'media:a',
4281
+ target_spec: 'media:c',
4282
+ steps: [
4283
+ makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4284
+ makeForEachStep('media:b'),
4285
+ makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
4286
+ ],
4287
+ };
4288
+ const built = rendererBuildStrandGraphData(payload);
4289
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4290
+
4291
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4292
+ assertEqual(JSON.stringify(nodeIds),
4293
+ JSON.stringify(['input_slot', 'step_0', 'step_2']),
4294
+ 'foreach node removed and output merged into step_2 (same URN as target)');
4295
+
4296
+ // Exactly one edge from step_0 to step_2, labeled with cap_b's
4297
+ // title + (1→n) marker.
4298
+ const step0ToStep2 = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
4299
+ assertEqual(step0ToStep2.length, 1,
4300
+ 'exactly one step_0 → step_2 edge after dropping the foreach iteration');
4301
+ assertEqual(step0ToStep2[0].label, 'b (1\u2192n)',
4302
+ 'the foreach-entry edge is labeled "<cap_b_title> (1→n)"');
4303
+
4304
+ // Cap_a's edge is unchanged (not inside a foreach body).
4305
+ const capA = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4306
+ assert(capA !== undefined, 'cap_a edge input_slot → step_0 exists');
4307
+ assertEqual(capA.label, 'a', 'cap_a edge carries just its title (no cardinality marker since 1→1)');
4308
+
4309
+ // After merging, step_2 becomes the render target — no separate
4310
+ // output node exists.
4311
+ const outputNode = collapsed.nodes.find(n => n.id === 'output');
4312
+ assertEqual(outputNode, undefined,
4313
+ 'output node was merged into step_2 because their URNs are semantically equivalent');
4314
+ const mergedTarget = collapsed.nodes.find(n => n.id === 'step_2');
4315
+ assertEqual(mergedTarget.nodeClass, 'strand-target',
4316
+ 'merged step_2 takes on the strand-target role');
4317
+ }
4318
+
4319
+ function testRenderer_collapseStrand_standaloneCollectCollapses() {
4320
+ // [Cap, Collect] with no enclosing ForEach, source=media:a,
4321
+ // target=media:b;list (NOT equivalent to cap's to_spec media:b,
4322
+ // so the output node is retained after collapse).
4323
+ //
4324
+ // Collapse:
4325
+ // - step_1 (standalone Collect) removed.
4326
+ // - Synthesized bridging edge step_0 → output labeled "collect".
4327
+ // - The cap edge input_slot → step_0 is unchanged because the
4328
+ // cap is not inside any foreach body.
4329
+ //
4330
+ // Final: 3 nodes (input_slot, step_0, output), 2 edges.
4331
+ const payload = {
4332
+ source_spec: 'media:a',
4333
+ target_spec: 'media:b;list',
4334
+ steps: [
4335
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4336
+ makeCollectStep('media:b'),
4337
+ ],
4338
+ };
4339
+ const built = rendererBuildStrandGraphData(payload);
4340
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4341
+
4342
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4343
+ assertEqual(JSON.stringify(nodeIds),
4344
+ JSON.stringify(['input_slot', 'output', 'step_0']),
4345
+ 'collect node removed; only cap + source + target remain');
4346
+
4347
+ const capEdge = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4348
+ assert(capEdge !== undefined, 'cap edge survives');
4349
+ assertEqual(capEdge.label, 'x',
4350
+ 'cap edge carries just its title — no foreach cardinality markers because the cap is not inside a foreach body');
4351
+
4352
+ const collectEdge = collapsed.edges.find(e => e.source === 'step_0' && e.target === 'output');
4353
+ assert(collectEdge !== undefined, 'step_0 → output edge synthesized by collect collapse');
4354
+ assertEqual(collectEdge.label, 'collect',
4355
+ 'the synthesized bridging edge for a standalone Collect is labeled "collect"');
4356
+ }
4357
+
4358
+ function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
4359
+ // Regression test mirroring the user's real strand:
4360
+ // [Cap_disbind (output_is_sequence=true), ForEach, Cap_make_decision],
4361
+ // source = media:pdf, target = media:decision (equivalent to
4362
+ // the last cap's to_spec).
4363
+ //
4364
+ // Expected render shape after collapse:
4365
+ // input_slot → step_0 labeled "Disbind (1→n)" — from Disbind's
4366
+ // own output_is_sequence flag, computed at build time.
4367
+ // step_0 → step_2 labeled "Make a Decision (1→n)" — because
4368
+ // make_decision is the first cap inside an unclosed
4369
+ // ForEach body (foreachEntry=true).
4370
+ // No separate output node because step_2's to_spec equals the
4371
+ // strand target.
4372
+ //
4373
+ // If this test fails, the runtime bug would manifest as either
4374
+ // (a) a duplicate target node, (b) a "for each" labeled edge
4375
+ // where the cap title should be, or (c) the phantom direct cap
4376
+ // edge not being relabeled.
4377
+ const payload = {
4378
+ source_spec: 'media:pdf',
4379
+ target_spec: 'media:decision',
4380
+ steps: [
4381
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4382
+ makeForEachStep('media:page'),
4383
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4384
+ ],
4385
+ };
4386
+ const built = rendererBuildStrandGraphData(payload);
4387
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4388
+
4389
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4390
+ assertEqual(JSON.stringify(nodeIds),
4391
+ JSON.stringify(['input_slot', 'step_0', 'step_2']),
4392
+ 'foreach node and duplicate output node both removed');
4393
+
4394
+ // Disbind cap edge carries its own (1→n) marker from
4395
+ // output_is_sequence=true, NOT from the foreach flag.
4396
+ const disbind = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4397
+ assert(disbind !== undefined, 'Disbind edge input_slot → step_0 exists');
4398
+ assertEqual(disbind.label, 'Disbind (1\u2192n)',
4399
+ 'Disbind edge reflects its own output_is_sequence=true cardinality');
4400
+
4401
+ // make_decision cap edge is the foreach entry — the plan-builder
4402
+ // phantom direct edge becomes the render-visible cap edge with
4403
+ // (1→n) appended to the cap title.
4404
+ const makeDecision = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
4405
+ assertEqual(makeDecision.length, 1,
4406
+ 'exactly one edge from Text Page to Decision (phantom not duplicated)');
4407
+ assertEqual(makeDecision[0].label, 'Make a Decision (1\u2192n)',
4408
+ 'the foreach entry edge is labeled "<cap_title> (1→n)", not "for each"');
4409
+
4410
+ // Duplicate target must be gone.
4411
+ const outputNode = collapsed.nodes.find(n => n.id === 'output');
4412
+ assertEqual(outputNode, undefined,
4413
+ 'output node merged into step_2 because they represent the same URN');
4414
+ }
4415
+
4416
+ function testRenderer_collapseStrand_plainCapMergesTrailingOutput() {
4417
+ // A strand with a single plain 1→1 cap whose to_spec equals
4418
+ // target_spec. The plan-builder topology produces:
4419
+ // input_slot → step_0 (cap) → output
4420
+ // The collapse pass merges the trailing output edge because
4421
+ // step_0 and output represent the same URN (media:b).
4422
+ //
4423
+ // Final: 2 nodes (input_slot, step_0), 1 edge.
4424
+ const payload = {
4425
+ source_spec: 'media:a',
4426
+ target_spec: 'media:b',
4427
+ steps: [
4428
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4429
+ ],
4430
+ };
4431
+ const built = rendererBuildStrandGraphData(payload);
4432
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4433
+
4434
+ assertEqual(collapsed.nodes.length, 2,
4435
+ 'duplicate output node merged into step_0 — 2 nodes remain');
4436
+ const outputNode = collapsed.nodes.find(n => n.id === 'output');
4437
+ assertEqual(outputNode, undefined,
4438
+ 'output node dropped by merge');
4439
+ const mergedTarget = collapsed.nodes.find(n => n.id === 'step_0');
4440
+ assertEqual(mergedTarget.nodeClass, 'strand-target',
4441
+ 'step_0 takes on the strand-target role after the merge');
4442
+
4443
+ assertEqual(collapsed.edges.length, 1, 'single cap edge remains');
4444
+ assertEqual(collapsed.edges[0].source, 'input_slot');
4445
+ assertEqual(collapsed.edges[0].target, 'step_0');
4446
+ assertEqual(collapsed.edges[0].label, 'x', 'cap title preserved as edge label');
4447
+ }
4448
+
4449
+ function testRenderer_collapseStrand_plainCapDistinctTargetNoMerge() {
4450
+ // A strand with a single plain cap whose to_spec is NOT
4451
+ // equivalent to target_spec. The output node must be retained
4452
+ // and the trailing connector edge preserved.
4453
+ const payload = {
4454
+ source_spec: 'media:a',
4455
+ target_spec: 'media:b;list',
4456
+ steps: [
4457
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4458
+ ],
4459
+ };
4460
+ const built = rendererBuildStrandGraphData(payload);
4461
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4462
+
4463
+ assertEqual(collapsed.nodes.length, 3,
4464
+ 'no merge because cap to_spec (media:b) and target (media:b;list) are semantically distinct');
4465
+ assert(collapsed.nodes.find(n => n.id === 'output') !== undefined,
4466
+ 'output node retained');
4467
+ assert(collapsed.nodes.find(n => n.id === 'step_0') !== undefined,
4468
+ 'step_0 retained');
4469
+ }
4470
+
4207
4471
  function testRenderer_validateStrandPayload_missingSourceSpec() {
4208
4472
  let threw = false;
4209
4473
  try {
@@ -4372,6 +4636,156 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
4372
4636
  assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
4373
4637
  }
4374
4638
 
4639
+ function testRenderer_buildRunGraphData_backboneHasNoForeachNode() {
4640
+ // Regression test for the run-mode rendering fix: the backbone
4641
+ // delivered to cytoscape must NOT contain any strand-foreach or
4642
+ // strand-collect nodes. Run mode inherits the same cosmetic
4643
+ // collapse as strand mode so the foreach/collect execution-layer
4644
+ // concepts don't leak into the view as boxed nodes.
4645
+ //
4646
+ // User scenario: [Disbind (1→n), ForEach, make_decision] where
4647
+ // target_spec equals the last cap's to_spec, so the backbone
4648
+ // collapses to 3 nodes: input_slot, step_0 (Text Page),
4649
+ // step_2 (Decision, merged target). No separate `for each` or
4650
+ // `collect` boxes.
4651
+ const strand = {
4652
+ source_spec: 'media:pdf',
4653
+ target_spec: 'media:decision',
4654
+ steps: [
4655
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4656
+ makeForEachStep('media:page'),
4657
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4658
+ ],
4659
+ };
4660
+ const payload = {
4661
+ resolved_strand: strand,
4662
+ body_outcomes: [],
4663
+ visible_success_count: 0,
4664
+ visible_failure_count: 0,
4665
+ total_body_count: 0,
4666
+ };
4667
+ const built = rendererBuildRunGraphData(payload);
4668
+
4669
+ // Backbone must contain NO foreach/collect nodes.
4670
+ const foreachNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-foreach');
4671
+ const collectNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-collect');
4672
+ assertEqual(foreachNodes.length, 0, 'run backbone must not contain strand-foreach nodes');
4673
+ assertEqual(collectNodes.length, 0, 'run backbone must not contain strand-collect nodes');
4674
+
4675
+ // The backbone fallback connector is the foreach-entry cap edge
4676
+ // that runs from the pre-foreach node to the body cap. It must
4677
+ // survive collapse so the target stays reachable even with zero
4678
+ // successful bodies.
4679
+ const backboneCapEdges = built.strandBuilt.edges.filter(e => e.edgeClass === 'strand-cap-edge');
4680
+ assert(backboneCapEdges.some(e => e.source === 'step_0' && e.target === 'step_2'),
4681
+ 'foreach-entry backbone edge step_0 → step_2 must be present for fallback connectivity');
4682
+
4683
+ // With zero outcomes, no replicas and no show-more nodes.
4684
+ assertEqual(built.replicaNodes.length, 0, 'no replica nodes when body_outcomes is empty');
4685
+ assertEqual(built.showMoreNodes.length, 0, 'no show-more nodes when no hidden outcomes');
4686
+ }
4687
+
4688
+ function testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
4689
+ // When every body fails, the strand target node was never
4690
+ // reached by any execution. The render drops BOTH the backbone
4691
+ // foreach-entry edge AND the orphaned target node so the user
4692
+ // doesn't see a stale "Decision" placeholder alongside their
4693
+ // failed replicas.
4694
+ const strand = {
4695
+ source_spec: 'media:pdf',
4696
+ target_spec: 'media:decision',
4697
+ steps: [
4698
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4699
+ makeForEachStep('media:page'),
4700
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4701
+ ],
4702
+ };
4703
+ const failedCapUrn = 'cap:in="media:page";op=decide;out="media:decision"';
4704
+ const payload = {
4705
+ resolved_strand: strand,
4706
+ body_outcomes: [
4707
+ { body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
4708
+ { body_index: 1, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
4709
+ ],
4710
+ visible_success_count: 3,
4711
+ visible_failure_count: 3,
4712
+ total_body_count: 2,
4713
+ };
4714
+ const built = rendererBuildRunGraphData(payload);
4715
+
4716
+ // The dropped placeholder: step_2 (the merged strand target
4717
+ // "Decision") is absent from the backbone because all bodies
4718
+ // failed and the replicas didn't reach it.
4719
+ const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
4720
+ assertEqual(hasStep2, false,
4721
+ 'strand target placeholder must be dropped when zero successful replicas reach it');
4722
+
4723
+ // The backbone foreach-entry edge is also gone — replicas
4724
+ // replaced it and there's no orphan target to connect.
4725
+ const foreachEntry = built.strandBuilt.edges.find(e =>
4726
+ e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
4727
+ assertEqual(foreachEntry, undefined,
4728
+ 'backbone foreach-entry edge must be dropped when replicas exist');
4729
+
4730
+ // Failures are rendered as red replica trails that don't merge.
4731
+ const failureNodes = built.replicaNodes.filter(n => n.classes === 'body-failure');
4732
+ assertEqual(failureNodes.length, 2,
4733
+ 'two failed bodies render two replica nodes (truncated at failed_cap)');
4734
+
4735
+ // The pre-foreach cap node (step_0, "Disbind") is still present
4736
+ // along with its incoming edge — only the post-body parts were
4737
+ // dropped.
4738
+ const hasStep0 = built.strandBuilt.nodes.some(n => n.id === 'step_0');
4739
+ assertEqual(hasStep0, true, 'pre-foreach cap node survives');
4740
+ const disbindEdge = built.strandBuilt.edges.find(e =>
4741
+ e.source === 'input_slot' && e.target === 'step_0');
4742
+ assert(disbindEdge !== undefined, 'pre-foreach cap edge (Disbind) survives');
4743
+ }
4744
+
4745
+ function testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful() {
4746
+ // When at least one successful replica merges into the target,
4747
+ // the backbone foreach-entry edge is dropped — the replica's
4748
+ // own chain represents the execution and the backbone would
4749
+ // otherwise duplicate the path.
4750
+ const strand = {
4751
+ source_spec: 'media:pdf',
4752
+ target_spec: 'media:decision',
4753
+ steps: [
4754
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4755
+ makeForEachStep('media:page'),
4756
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4757
+ ],
4758
+ };
4759
+ const payload = {
4760
+ resolved_strand: strand,
4761
+ body_outcomes: [
4762
+ { body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
4763
+ ],
4764
+ visible_success_count: 3,
4765
+ visible_failure_count: 3,
4766
+ total_body_count: 1,
4767
+ };
4768
+ const built = rendererBuildRunGraphData(payload);
4769
+
4770
+ // The backbone foreach-entry edge is gone.
4771
+ const foreachEntry = built.strandBuilt.edges.find(e =>
4772
+ e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
4773
+ assertEqual(foreachEntry, undefined,
4774
+ 'foreach-entry backbone edge dropped when at least one success exists');
4775
+
4776
+ // step_2 (target) stays because the replica merge edge lands on it.
4777
+ const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
4778
+ assertEqual(hasStep2, true,
4779
+ 'strand target node survives when successful replicas merge into it');
4780
+
4781
+ // Exactly one successful replica node and one merge edge.
4782
+ const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
4783
+ assertEqual(successNodes.length, 1, 'one replica node for one successful body');
4784
+ const mergeEdges = built.replicaEdges.filter(e =>
4785
+ e.data && e.data.target === 'step_2' && e.classes === 'body-success');
4786
+ assertEqual(mergeEdges.length, 1, 'one merge edge from replica to target');
4787
+ }
4788
+
4375
4789
  // ---------------- machine builder ----------------
4376
4790
 
4377
4791
  function testRenderer_validateMachinePayload_rejectsUnknownKind() {
@@ -4800,6 +5214,12 @@ async function runTests() {
4800
5214
  runTest('RENDERER: buildStrand_standaloneCollect', testRenderer_buildStrandGraphData_standaloneCollect);
4801
5215
  runTest('RENDERER: buildStrand_unclosedForEachBody', testRenderer_buildStrandGraphData_unclosedForEachBody);
4802
5216
  runTest('RENDERER: buildStrand_nestedForEachThrows', testRenderer_buildStrandGraphData_nestedForEachThrows);
5217
+ runTest('RENDERER: collapseStrand_singleCapBody', testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker);
5218
+ runTest('RENDERER: collapseStrand_unclosedForEachBody', testRenderer_collapseStrand_unclosedForEachBodyCollapses);
5219
+ runTest('RENDERER: collapseStrand_standaloneCollect', testRenderer_collapseStrand_standaloneCollectCollapses);
5220
+ runTest('RENDERER: collapseStrand_seqCapBeforeForeach', testRenderer_collapseStrand_sequenceProducingCapBeforeForeach);
5221
+ runTest('RENDERER: collapseStrand_plainCapMergesOutput', testRenderer_collapseStrand_plainCapMergesTrailingOutput);
5222
+ runTest('RENDERER: collapseStrand_plainCapDistinctTarget', testRenderer_collapseStrand_plainCapDistinctTargetNoMerge);
4803
5223
  runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
4804
5224
 
4805
5225
  console.log('\n--- cap-graph-renderer run builder ---');
@@ -4807,6 +5227,9 @@ async function runTests() {
4807
5227
  runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
4808
5228
  runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
4809
5229
  runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
5230
+ runTest('RENDERER: buildRun_backboneHasNoForeachNode', testRenderer_buildRunGraphData_backboneHasNoForeachNode);
5231
+ runTest('RENDERER: buildRun_allFailedDropsPlaceholder', testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
5232
+ runTest('RENDERER: buildRun_backboneDroppedWhenSuccessful', testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful);
4810
5233
 
4811
5234
  console.log('\n--- cap-graph-renderer machine builder ---');
4812
5235
  runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  "pretest": "npm run build:parser",
41
41
  "test": "node capdag.test.js"
42
42
  },
43
- "version": "0.109.248"
43
+ "version": "0.119.263"
44
44
  }