capdag 0.106.243 → 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,
@@ -663,22 +667,38 @@ function classifyStrandCapSteps(steps) {
663
667
  return { capStepIndices, capFlags };
664
668
  }
665
669
 
666
- // Build the strand graph. Every Cap step becomes an edge
667
- // (`from_spec` → `to_spec`). ForEach / Collect are NOT rendered as nodes
668
- // they are transitions that annotate the adjacent cap edges:
670
+ // Build the strand graph by mirroring capdag's plan builder
671
+ // (`capdag/src/planner/plan_builder.rs::build_plan_from_path`). The plan
672
+ // builder is the authoritative source of truth for how strand steps
673
+ // translate into a DAG of nodes and edges:
669
674
  //
670
- // * A Cap whose previous step is ForEach gets the prefix "for each"
671
- // on its edge label (the first-body entry edge).
672
- // * A Cap whose next step is Collect gets the suffix "collect" on its
673
- // edge label (the last-body exit edge).
675
+ // * Node IDs are positional: `input_slot`, `step_0`, `step_1`, …,
676
+ // `output`. They are NOT media URN strings — URN comparisons for
677
+ // graph topology are wrong because the planner connects steps by
678
+ // the order-theoretic `conformsTo` relation, not by string equality.
679
+ // * `prev_node_id` is a single running pointer, only advanced by Cap
680
+ // steps. ForEach steps mark the start of a body span without
681
+ // advancing prev; the body's first Cap still connects to whatever
682
+ // was before the ForEach.
683
+ // * Cap inside a ForEach body connects from `prev_node_id` like any
684
+ // other cap, AND tracks `body_entry` (first cap in body) and
685
+ // `body_exit` (most recent cap in body).
686
+ // * Collect after a ForEach body creates a ForEach node with
687
+ // boundaries, an iteration edge to body_entry, a Collect node, and
688
+ // a collection edge from body_exit to Collect. prev_node_id becomes
689
+ // the Collect node.
690
+ // * Standalone Collect (no enclosing ForEach) creates a Collect node
691
+ // consuming prev_node_id directly.
692
+ // * Unclosed ForEach with no body caps is a terminal unwrap — the
693
+ // ForEach node is skipped; prev_node_id stays as-is.
694
+ // * Unclosed ForEach WITH body caps gets a ForEach node, iteration
695
+ // edge to body_entry, and prev_node_id becomes body_exit.
674
696
  //
675
- // Source-to-body and body-to-target continuity is guaranteed by the
676
- // strand structure itself: consecutive cap steps satisfy
677
- // `stepN.to_spec == stepN+1.from_spec`, and fix-up edges are added
678
- // explicitly when the strand's `source_spec` differs from the first cap
679
- // step's `from_spec` (the ForEach shape transition) or when the strand's
680
- // `target_spec` differs from the last cap step's `to_spec` (the Collect
681
- // shape transition).
697
+ // Node labels come from the `media_display_names` map keyed by the
698
+ // step's canonical URN (or source_spec/target_spec for the boundary
699
+ // nodes). ForEach and Collect nodes display "for each" / "collect".
700
+ // Cap edges carry the cap title plus cardinality marker when either
701
+ // input or output is a sequence.
682
702
  function buildStrandGraphData(data) {
683
703
  validateStrandPayload(data);
684
704
 
@@ -686,158 +706,520 @@ function buildStrandGraphData(data) {
686
706
  const sourceSpec = canonicalMediaUrn(data.source_spec);
687
707
  const targetSpec = canonicalMediaUrn(data.target_spec);
688
708
 
689
- const canonicalDisplayLookup = new Map();
709
+ // Look up a display name for a media URN via the host-supplied map.
710
+ // Uses `MediaUrn.isEquivalent` so tag-order variation doesn't defeat
711
+ // the lookup — URNs are compared semantically, never as raw strings.
712
+ const MediaUrn = requireHostDependency('MediaUrn');
713
+ const displayEntries = [];
690
714
  for (const [urn, display] of Object.entries(mediaDisplayNames)) {
691
- if (typeof display === 'string' && display.length > 0) {
692
- canonicalDisplayLookup.set(canonicalMediaUrn(urn), display);
715
+ if (typeof display !== 'string' || display.length === 0) continue;
716
+ try {
717
+ displayEntries.push({ media: MediaUrn.fromString(urn), display });
718
+ } catch (_) {
719
+ // Skip entries with unparseable URN keys — the host payload is
720
+ // trusted, but malformed keys are not fatal.
721
+ }
722
+ }
723
+ function displayNameFor(canonicalUrn) {
724
+ const candidate = MediaUrn.fromString(canonicalUrn);
725
+ for (const entry of displayEntries) {
726
+ if (candidate.isEquivalent(entry.media)) return entry.display;
693
727
  }
728
+ return mediaNodeLabel(canonicalUrn);
694
729
  }
695
730
 
696
- const nodesMap = new Map();
697
- function ensureNode(canonicalUrn) {
698
- if (!nodesMap.has(canonicalUrn)) {
699
- const displayName = canonicalDisplayLookup.get(canonicalUrn);
700
- nodesMap.set(canonicalUrn, {
701
- id: canonicalUrn,
702
- label: displayName !== undefined ? displayName : mediaNodeLabel(canonicalUrn),
703
- fullUrn: canonicalUrn,
704
- });
731
+ const nodes = [];
732
+ const edges = [];
733
+ const nodeIds = new Set();
734
+
735
+ function addNode(id, label, fullUrn, nodeClass) {
736
+ if (nodeIds.has(id)) return;
737
+ nodeIds.add(id);
738
+ nodes.push({
739
+ id,
740
+ label,
741
+ fullUrn: fullUrn || '',
742
+ nodeClass: nodeClass || '',
743
+ });
744
+ }
745
+ let edgeCounter = 0;
746
+ function addEdge(source, target, label, title, fullUrn, edgeClass, meta) {
747
+ const m = meta || {};
748
+ edges.push({
749
+ id: `strand-edge-${edgeCounter}`,
750
+ source,
751
+ target,
752
+ label: label || '',
753
+ title: title || '',
754
+ fullUrn: fullUrn || '',
755
+ edgeClass: edgeClass || '',
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,
767
+ });
768
+ edgeCounter++;
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
+ }
705
781
  }
706
- return canonicalUrn;
707
782
  }
708
783
 
709
- ensureNode(sourceSpec);
710
- ensureNode(targetSpec);
784
+ // Entry node — the strand's source media spec.
785
+ const inputSlotId = 'input_slot';
786
+ addNode(inputSlotId, displayNameFor(sourceSpec), sourceSpec, 'strand-source');
711
787
 
712
- const { capStepIndices } = classifyStrandCapSteps(data.steps);
713
- const firstCapIdx = capStepIndices.length > 0 ? capStepIndices[0] : -1;
714
- const lastCapIdx = capStepIndices.length > 0 ? capStepIndices[capStepIndices.length - 1] : -1;
715
- const hasLeadingForEach = data.steps.some((s, i) =>
716
- Object.keys(s.step_type)[0] === 'ForEach' && i < (firstCapIdx === -1 ? Infinity : firstCapIdx));
717
- const hasTrailingCollect = data.steps.some((s, i) =>
718
- Object.keys(s.step_type)[0] === 'Collect' && i > (lastCapIdx === -1 ? -Infinity : lastCapIdx));
788
+ let prevNodeId = inputSlotId;
719
789
 
720
- const edges = [];
721
- let capEdgeIdx = 0;
790
+ // Track ForEach body membership. `insideForEachBody = { index, nodeId }`
791
+ // records which ForEach step we're inside and the id we'll give its
792
+ // eventual node. `bodyEntry`/`bodyExit` track the first and most
793
+ // recent Cap step inside that body.
794
+ let insideForEachBody = null;
795
+ let bodyEntry = null;
796
+ let bodyExit = null;
722
797
 
723
- // Prepend a fix-up edge from source_spec to the first cap step's
724
- // from_spec when they differ. This is the ForEach shape transition
725
- // rendered as an explicit edge.
726
- if (firstCapIdx >= 0) {
727
- const firstCap = data.steps[firstCapIdx];
728
- const firstCapFrom = canonicalMediaUrn(firstCap.from_spec);
729
- if (firstCapFrom !== sourceSpec) {
730
- ensureNode(firstCapFrom);
731
- const label = hasLeadingForEach ? 'for each' : '';
732
- edges.push({
733
- id: `strand-edge-${capEdgeIdx}`,
734
- source: sourceSpec,
735
- target: firstCapFrom,
736
- title: label || 'fan-out',
737
- label,
738
- cardinality: '',
739
- capUrn: '',
740
- color: edgeHueColor(capEdgeIdx),
741
- });
742
- capEdgeIdx++;
743
- }
798
+ // Finalize an outer ForEach body when a nested ForEach starts before
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.
803
+ function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
804
+ const outerForEachInput = outerForEach.index === 0
805
+ ? inputSlotId
806
+ : `step_${outerForEach.index - 1}`;
807
+ addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
808
+ addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
809
+ addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
810
+ return outerExit;
744
811
  }
745
812
 
746
- // Cap step edges. Each Cap's edge connects its from_spec to its
747
- // to_spec. Adjacent cap edges are continuous because the planner
748
- // guarantees stepN.to_spec == stepN+1.from_spec. "for each" and
749
- // "collect" labels belong on the fix-up edges around the cap chain,
750
- // not on the cap edges themselves — those labels describe shape
751
- // transitions between the source list and the first body scalar (and
752
- // between the last body scalar and the target list), which are the
753
- // fix-up edges.
754
- data.steps.forEach((step) => {
813
+ data.steps.forEach((step, i) => {
755
814
  const variant = Object.keys(step.step_type)[0];
756
- if (variant !== 'Cap') return;
757
- const body = step.step_type.Cap;
758
- const fromCanonical = canonicalMediaUrn(step.from_spec);
759
- const toCanonical = canonicalMediaUrn(step.to_spec);
760
- ensureNode(fromCanonical);
761
- ensureNode(toCanonical);
815
+ const nodeId = `step_${i}`;
816
+
817
+ if (variant === 'Cap') {
818
+ const body = step.step_type.Cap;
819
+ const toCanonical = canonicalMediaUrn(step.to_spec);
820
+ addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
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
+
829
+ let label = body.title;
830
+ const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
831
+ if (cardinality !== '1\u21921') {
832
+ label = `${label} (${cardinality})`;
833
+ }
834
+ addEdge(
835
+ prevNodeId,
836
+ nodeId,
837
+ label,
838
+ body.title,
839
+ body.cap_urn,
840
+ 'strand-cap-edge',
841
+ { foreachEntry: isForeachEntry }
842
+ );
843
+
844
+ if (insideForEachBody !== null) {
845
+ if (bodyEntry === null) bodyEntry = nodeId;
846
+ bodyExit = nodeId;
847
+ }
762
848
 
763
- let label = body.title;
764
- const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
765
- if (cardinality !== '1\u21921') {
766
- label = `${label} (${cardinality})`;
849
+ prevNodeId = nodeId;
850
+ return;
767
851
  }
768
852
 
769
- edges.push({
770
- id: `strand-edge-${capEdgeIdx}`,
771
- source: fromCanonical,
772
- target: toCanonical,
773
- title: body.title,
774
- label,
775
- cardinality,
776
- capUrn: body.cap_urn,
777
- color: edgeHueColor(capEdgeIdx),
778
- });
779
- capEdgeIdx++;
853
+ if (variant === 'ForEach') {
854
+ // If we're already inside a ForEach body when another ForEach
855
+ // starts, finalize the outer one first.
856
+ if (insideForEachBody !== null) {
857
+ const outer = insideForEachBody;
858
+ const entry = bodyEntry !== null ? bodyEntry : prevNodeId;
859
+ const exit = bodyExit !== null ? bodyExit : prevNodeId;
860
+ if (bodyEntry === null) {
861
+ // Outer ForEach with no body caps is an illegal nesting; the
862
+ // plan builder throws. Mirror that.
863
+ throw new Error(
864
+ `CapGraphRenderer strand: nested ForEach at step[${i}] but outer ForEach at step[${outer.index}] has no body caps`
865
+ );
866
+ }
867
+ prevNodeId = finalizeOuterForEach(outer, entry, exit);
868
+ bodyEntry = null;
869
+ bodyExit = null;
870
+ }
871
+ insideForEachBody = { index: i, nodeId };
872
+ bodyEntry = null;
873
+ bodyExit = null;
874
+ // Do NOT advance prevNodeId — the body's first cap will connect
875
+ // to whatever was before the ForEach.
876
+ return;
877
+ }
878
+
879
+ if (variant === 'Collect') {
880
+ if (insideForEachBody !== null) {
881
+ const outer = insideForEachBody;
882
+ const entry = bodyEntry !== null ? bodyEntry : prevNodeId;
883
+ const exit = bodyExit !== null ? bodyExit : prevNodeId;
884
+ const outerForEachInput = outer.index === 0
885
+ ? inputSlotId
886
+ : `step_${outer.index - 1}`;
887
+
888
+ addNode(outer.nodeId, 'for each', '', 'strand-foreach');
889
+ addEdge(outerForEachInput, outer.nodeId, 'for each', 'for each', '', 'strand-iteration');
890
+ addEdge(outer.nodeId, entry, '', '', '', 'strand-iteration');
891
+
892
+ addNode(nodeId, 'collect', '', 'strand-collect');
893
+ addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
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
+
904
+ insideForEachBody = null;
905
+ bodyEntry = null;
906
+ bodyExit = null;
907
+ prevNodeId = nodeId;
908
+ } else {
909
+ // Standalone Collect — scalar → list-of-one. Mirrors
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.
915
+ addNode(nodeId, 'collect', '', 'strand-collect');
916
+ addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
917
+ prevNodeId = nodeId;
918
+ }
919
+ return;
920
+ }
921
+
922
+ throw new Error(`CapGraphRenderer strand: unknown step_type variant '${variant}' at step[${i}]`);
780
923
  });
781
924
 
782
- // Append a fix-up edge from the last cap's to_spec to target_spec
783
- // when they differ. This is the Collect shape transition as an
784
- // explicit edge.
785
- if (lastCapIdx >= 0) {
786
- const lastCap = data.steps[lastCapIdx];
787
- const lastCapTo = canonicalMediaUrn(lastCap.to_spec);
788
- if (lastCapTo !== targetSpec) {
789
- const label = hasTrailingCollect ? 'collect' : '';
790
- edges.push({
791
- id: `strand-edge-${capEdgeIdx}`,
792
- source: lastCapTo,
793
- target: targetSpec,
794
- title: label || 'fan-in',
795
- label,
796
- cardinality: '',
797
- capUrn: '',
798
- color: edgeHueColor(capEdgeIdx),
799
- });
800
- capEdgeIdx++;
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.
930
+ if (insideForEachBody !== null) {
931
+ const outer = insideForEachBody;
932
+ const hasBodyEntry = bodyEntry !== null;
933
+ if (hasBodyEntry) {
934
+ const entry = bodyEntry;
935
+ const exit = bodyExit;
936
+ const outerForEachInput = outer.index === 0
937
+ ? inputSlotId
938
+ : `step_${outer.index - 1}`;
939
+ addNode(outer.nodeId, 'for each', '', 'strand-foreach');
940
+ addEdge(outerForEachInput, outer.nodeId, 'for each', 'for each', '', 'strand-iteration');
941
+ addEdge(outer.nodeId, entry, '', '', '', 'strand-iteration');
942
+ prevNodeId = exit;
943
+ }
944
+ // hasBodyEntry === false is a terminal unwrap — skip the ForEach
945
+ // node entirely, prev_node_id stays as-is.
946
+ insideForEachBody = null;
947
+ bodyEntry = null;
948
+ bodyExit = null;
949
+ }
950
+
951
+ // Final output node. Mirrors plan_builder.rs:430-432.
952
+ const outputId = 'output';
953
+ addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
954
+ addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
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.
961
+ return { nodes, edges, sourceSpec, targetSpec };
962
+ }
963
+
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
+ }
801
1111
  }
802
1112
  }
803
1113
 
804
- // Edge case: if there are no Cap steps at all, still connect source
805
- // to target directly so the graph has at least one edge. This handles
806
- // degenerate strands (e.g. identity).
807
- if (firstCapIdx === -1 && sourceSpec !== targetSpec) {
808
- edges.push({
809
- id: `strand-edge-${capEdgeIdx}`,
810
- source: sourceSpec,
811
- target: targetSpec,
812
- title: '',
813
- label: '',
814
- cardinality: '',
815
- capUrn: '',
816
- color: edgeHueColor(capEdgeIdx),
817
- });
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
+ }
818
1196
  }
819
1197
 
820
- return {
821
- nodes: Array.from(nodesMap.values()),
822
- edges,
823
- sourceSpec,
824
- targetSpec,
825
- };
1198
+ return { nodes, edges };
826
1199
  }
827
1200
 
828
- function strandCytoscapeElements(built) {
829
- const nodeElements = built.nodes.map(node => ({
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 => ({
830
1214
  group: 'nodes',
831
1215
  data: {
832
1216
  id: node.id,
833
1217
  label: node.label,
834
1218
  fullUrn: node.fullUrn,
835
1219
  },
836
- classes: node.id === built.sourceSpec ? 'strand-source'
837
- : node.id === built.targetSpec ? 'strand-target'
838
- : '',
1220
+ classes: node.nodeClass || '',
839
1221
  }));
840
- const edgeElements = built.edges.map(edge => ({
1222
+ const edgeElements = source.edges.map(edge => ({
841
1223
  group: 'edges',
842
1224
  data: {
843
1225
  id: edge.id,
@@ -845,10 +1227,10 @@ function strandCytoscapeElements(built) {
845
1227
  target: edge.target,
846
1228
  label: edge.label,
847
1229
  title: edge.title,
848
- cardinality: edge.cardinality,
849
- fullUrn: edge.capUrn,
1230
+ fullUrn: edge.fullUrn,
850
1231
  color: edge.color,
851
1232
  },
1233
+ classes: edge.edgeClass || '',
852
1234
  }));
853
1235
  return nodeElements.concat(edgeElements);
854
1236
  }
@@ -870,20 +1252,98 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
870
1252
  return -1;
871
1253
  }
872
1254
 
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;
1270
+ const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
1271
+
1272
+ const interiorStepIdxs = [];
1273
+ for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
1274
+ if (Object.keys(steps[i].step_type)[0] === 'Cap') {
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
+ }]);
1320
+ }
1321
+ }
1322
+
1323
+ return {
1324
+ nodes: keptNodes,
1325
+ edges: keptEdges,
1326
+ sourceSpec: built.sourceSpec,
1327
+ targetSpec: built.targetSpec,
1328
+ };
1329
+ }
1330
+
873
1331
  function buildRunGraphData(data) {
874
1332
  validateRunPayload(data);
875
1333
 
876
- // The backbone is rendered with the same rules as strand mode. Feed
877
- // the strand portion to the strand builder to inherit all its
878
- // 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.
879
1338
  const strandInput = Object.assign({}, data.resolved_strand, {
880
1339
  media_display_names: data.media_display_names,
881
1340
  });
882
- const strandBuilt = buildStrandGraphData(strandInput);
1341
+ const strandBuiltRaw = buildStrandGraphData(strandInput);
1342
+ const strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
883
1343
 
884
- // Locate the ForEach/Collect span in the backbone for body-replica
885
- // placement. We anchor body replicas to the first Cap step after a
886
- // ForEach (the fan-out point) and merge back at the first Collect.
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.
887
1347
  const steps = data.resolved_strand.steps;
888
1348
  let foreachStepIdx = -1;
889
1349
  let collectStepIdx = -1;
@@ -894,6 +1354,15 @@ function buildRunGraphData(data) {
894
1354
  }
895
1355
  const hasForeach = foreachStepIdx >= 0;
896
1356
 
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;
1365
+
897
1366
  // Filter and bound the outcomes.
898
1367
  const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
899
1368
  const successes = allOutcomes.filter(o => o.success);
@@ -903,10 +1372,8 @@ function buildRunGraphData(data) {
903
1372
  const hiddenSuccessCount = successes.length - visibleSuccess.length;
904
1373
  const hiddenFailureCount = failures.length - visibleFailure.length;
905
1374
 
906
- // Map each displayed body to its per-body chain. The chain starts at
907
- // the first Cap step's from_spec (the node immediately after the
908
- // ForEach) and extends through either all body caps (success) or up to
909
- // the failed_cap (failure).
1375
+ // Collect the Cap steps inside the ForEach body. Each body replica
1376
+ // chains through these caps.
910
1377
  const bodyCapSteps = [];
911
1378
  const bodyStart = hasForeach ? foreachStepIdx + 1 : 0;
912
1379
  const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
@@ -916,19 +1383,94 @@ function buildRunGraphData(data) {
916
1383
  }
917
1384
  }
918
1385
 
919
- // Replica node/edge ids are prefixed to avoid collision with backbone
920
- // ids.
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
+ }
1462
+
921
1463
  const replicaNodes = [];
922
1464
  const replicaEdges = [];
923
1465
 
924
- function buildBodyReplica(outcome, groupIndex) {
1466
+ function buildBodyReplica(outcome) {
925
1467
  const success = outcome.success;
926
1468
  const successClass = success ? 'body-success' : 'body-failure';
927
1469
  const edgeClass = success ? 'body-success' : 'body-failure';
928
1470
  const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
929
1471
 
930
- // Find the trace end (failed_cap stops the trace for failed
931
- // bodies). Comparison uses CapUrn.isEquivalent.
1472
+ // Trace end: failures stop at failed_cap. `CapUrn.isEquivalent`
1473
+ // is used for the match — never string equality.
932
1474
  let traceEnd = bodyCapSteps.length;
933
1475
  if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
934
1476
  const CapUrn = requireHostDependency('CapUrn');
@@ -941,12 +1483,9 @@ function buildRunGraphData(data) {
941
1483
  }
942
1484
  }
943
1485
  }
944
- if (traceEnd === 0) return; // Nothing to render for this body.
1486
+ if (traceEnd === 0) return;
945
1487
 
946
- // Anchor to the first body cap's from_spec, which equals the
947
- // ForEach's from_spec (fan-out from the same source node).
948
- const anchorCanonical = canonicalMediaUrn(bodyCapSteps[0].step.from_spec);
949
- let prevBodyNodeId = anchorCanonical;
1488
+ let prevBodyNodeId = anchorNodeId;
950
1489
  const bodyKey = `body-${outcome.body_index}`;
951
1490
  const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
952
1491
  ? outcome.title
@@ -973,6 +1512,11 @@ function buildRunGraphData(data) {
973
1512
  id: `${bodyKey}-e-${i}`,
974
1513
  source: prevBodyNodeId,
975
1514
  target: replicaNodeId,
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.
976
1520
  label: '',
977
1521
  title: body.title,
978
1522
  fullUrn: body.cap_urn,
@@ -984,16 +1528,17 @@ function buildRunGraphData(data) {
984
1528
  prevBodyNodeId = replicaNodeId;
985
1529
  }
986
1530
 
987
- // If the collect exists and the body succeeded, attach the replica
988
- // tail to the collect's to_spec node so the graph visibly fans in.
989
- if (success && collectStepIdx >= 0) {
990
- const collectTo = canonicalMediaUrn(steps[collectStepIdx].to_spec);
1531
+ // Successful bodies merge their replica tail back into the Collect
1532
+ // node (or `output` if the ForEach is unclosed) so the graph
1533
+ // visibly fans in. Failed bodies do NOT merge — the trace
1534
+ // terminates at the failed cap.
1535
+ if (success) {
991
1536
  replicaEdges.push({
992
1537
  group: 'edges',
993
1538
  data: {
994
1539
  id: `${bodyKey}-merge`,
995
1540
  source: prevBodyNodeId,
996
- target: collectTo,
1541
+ target: mergeNodeId,
997
1542
  label: '',
998
1543
  title: 'collect',
999
1544
  fullUrn: '',
@@ -1005,14 +1550,51 @@ function buildRunGraphData(data) {
1005
1550
  }
1006
1551
  }
1007
1552
 
1008
- visibleSuccess.forEach((o, i) => buildBodyReplica(o, i));
1009
- visibleFailure.forEach((o, i) => buildBodyReplica(o, i));
1553
+ visibleSuccess.forEach((o) => buildBodyReplica(o));
1554
+ visibleFailure.forEach((o) => buildBodyReplica(o));
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
+ }
1010
1593
 
1011
1594
  // Build success and failure "show more" nodes when there are hidden
1012
- // outcomes. Anchored at the same ForEach fan-out source.
1595
+ // outcomes. Anchored at the ForEach node (or input_slot if none).
1013
1596
  const showMoreNodes = [];
1014
1597
  if (hasForeach && bodyCapSteps.length > 0) {
1015
- const anchorCanonical = canonicalMediaUrn(bodyCapSteps[0].step.from_spec);
1016
1598
  if (hiddenSuccessCount > 0) {
1017
1599
  const nodeId = 'show-more-success';
1018
1600
  showMoreNodes.push({
@@ -1030,7 +1612,7 @@ function buildRunGraphData(data) {
1030
1612
  group: 'edges',
1031
1613
  data: {
1032
1614
  id: 'show-more-success-edge',
1033
- source: anchorCanonical,
1615
+ source: anchorNodeId,
1034
1616
  target: nodeId,
1035
1617
  label: '',
1036
1618
  title: '',
@@ -1057,7 +1639,7 @@ function buildRunGraphData(data) {
1057
1639
  group: 'edges',
1058
1640
  data: {
1059
1641
  id: 'show-more-failure-edge',
1060
- source: anchorCanonical,
1642
+ source: anchorNodeId,
1061
1643
  target: nodeId,
1062
1644
  label: '',
1063
1645
  title: '',
@@ -1085,7 +1667,10 @@ function buildRunGraphData(data) {
1085
1667
  }
1086
1668
 
1087
1669
  function runCytoscapeElements(built) {
1088
- 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 });
1089
1674
  return strandElements
1090
1675
  .concat(built.replicaNodes)
1091
1676
  .concat(built.showMoreNodes)
@@ -1988,6 +2573,7 @@ if (typeof module !== 'undefined' && module.exports) {
1988
2573
  mediaNodeLabel,
1989
2574
  buildBrowseGraphData,
1990
2575
  buildStrandGraphData,
2576
+ collapseStrandShapeTransitions,
1991
2577
  buildRunGraphData,
1992
2578
  buildMachineGraphData,
1993
2579
  classifyStrandCapSteps,