capdag 0.119.263 → 0.124.274

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,7 +185,7 @@ function layoutForMode(mode) {
185
185
  'elk.spacing.nodeNode': 35,
186
186
  });
187
187
  }
188
- if (mode === 'machine') {
188
+ if (mode === 'editor-graph') {
189
189
  // Editor graph is a small bipartite-ish DAG; orthogonal routing
190
190
  // reads more cleanly than polyline at this density.
191
191
  return {
@@ -199,6 +199,16 @@ function layoutForMode(mode) {
199
199
  'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
200
200
  };
201
201
  }
202
+ if (mode === 'machine') {
203
+ // Resolved-machine graph: a (potentially multi-strand) DAG laid
204
+ // out left-to-right with the same spacing the strand mode uses
205
+ // for single strands. Multiple disconnected strands are placed
206
+ // side by side by elk's component packer.
207
+ return Object.assign({}, base, {
208
+ 'elk.layered.spacing.nodeNodeBetweenLayers': 120,
209
+ 'elk.spacing.nodeNode': 40,
210
+ });
211
+ }
202
212
  throw new Error(`CapGraphRenderer: unknown mode '${mode}'`);
203
213
  }
204
214
 
@@ -222,6 +232,16 @@ function buildStylesheet() {
222
232
  const bodyEdgeSuccess = getCssVar('--graph-body-edge-success');
223
233
  const bodyEdgeFailure = getCssVar('--graph-body-edge-failure');
224
234
 
235
+ // Machine mode uses a dedicated palette exposed by the notation
236
+ // editor's CSS (`--graph-node-color`, `--graph-loop-color`,
237
+ // `--graph-edge-color`, `--graph-text`, `--graph-muted`). Caps
238
+ // are collapsed into edges so there's no cap-node color.
239
+ const machineNodeColor = getCssVar('--graph-node-color');
240
+ const machineLoopColor = getCssVar('--graph-loop-color');
241
+ const machineEdgeColor = getCssVar('--graph-edge-color');
242
+ const machineText = getCssVar('--graph-text');
243
+ const machineMuted = getCssVar('--graph-muted');
244
+
225
245
  return [
226
246
  {
227
247
  selector: 'node',
@@ -290,11 +310,14 @@ function buildStylesheet() {
290
310
  'font-weight': '500',
291
311
  'color': 'data(color)',
292
312
  'text-background-color': edgeTextBg,
293
- 'text-background-opacity': edgeTextBgOpacity,
294
- 'text-background-padding': '3px',
313
+ 'text-background-opacity': 1,
314
+ 'text-background-padding': '4px',
295
315
  'text-background-shape': 'roundrectangle',
296
- 'text-rotation': 'autorotate',
297
- 'text-margin-y': -8,
316
+ // Keep labels horizontal — autorotate clips along
317
+ // fan-out edges and rotates labels into unreadable
318
+ // angles when multiple edges share a source.
319
+ 'text-rotation': 0,
320
+ 'text-margin-y': 0,
298
321
  'curve-style': 'bezier',
299
322
  'control-point-step-size': 40,
300
323
  'width': 1.5,
@@ -320,11 +343,21 @@ function buildStylesheet() {
320
343
  },
321
344
  {
322
345
  selector: 'edge.body-success',
323
- style: { 'line-color': bodyEdgeSuccess, 'target-arrow-color': bodyEdgeSuccess },
346
+ style: {
347
+ 'line-color': bodyEdgeSuccess,
348
+ 'target-arrow-color': bodyEdgeSuccess,
349
+ // Label text uses the resolved success color too, not the
350
+ // unresolved `var(...)` string stored in `data.color`.
351
+ 'color': bodyEdgeSuccess,
352
+ },
324
353
  },
325
354
  {
326
355
  selector: 'edge.body-failure',
327
- style: { 'line-color': bodyEdgeFailure, 'target-arrow-color': bodyEdgeFailure },
356
+ style: {
357
+ 'line-color': bodyEdgeFailure,
358
+ 'target-arrow-color': bodyEdgeFailure,
359
+ 'color': bodyEdgeFailure,
360
+ },
328
361
  },
329
362
  {
330
363
  selector: 'node.path-highlighted',
@@ -334,6 +367,44 @@ function buildStylesheet() {
334
367
  selector: 'edge.path-highlighted',
335
368
  style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
336
369
  },
370
+
371
+ // -------- Machine / editor-graph mode ------------------------------
372
+ // Shared between the resolved-machine view (`mode: 'machine'`,
373
+ // canonical `Machine::to_render_payload_json`) and the notation
374
+ // editor's live graph (`mode: 'editor-graph'`, ast-driven). Both
375
+ // collapse each cap application into a single labeled edge
376
+ // between its input and output data slots — caps do NOT appear
377
+ // as separate nodes. Only data slots are nodes; cap semantics
378
+ // are carried on edges.
379
+ //
380
+ // Reads the editor's dedicated palette so nodes, edges and
381
+ // loop markers match the text theme.
382
+ {
383
+ selector: 'node.machine-node',
384
+ style: {
385
+ 'background-color': machineNodeColor || nodeBg,
386
+ 'border-color': machineNodeColor || nodeBorder,
387
+ 'color': machineText || nodeText,
388
+ 'border-opacity': 1,
389
+ },
390
+ },
391
+ {
392
+ selector: 'edge.machine-edge',
393
+ style: {
394
+ 'line-color': machineEdgeColor || 'data(color)',
395
+ 'target-arrow-color': machineEdgeColor || 'data(color)',
396
+ 'color': machineMuted || nodeText,
397
+ },
398
+ },
399
+ {
400
+ selector: 'edge.machine-loop',
401
+ style: {
402
+ 'line-color': machineLoopColor || nodeBorderActive,
403
+ 'target-arrow-color': machineLoopColor || nodeBorderActive,
404
+ 'color': machineLoopColor || nodeBorderActive,
405
+ 'line-style': 'dashed',
406
+ },
407
+ },
337
408
  ];
338
409
  }
339
410
 
@@ -448,6 +519,9 @@ function validateStrandPayload(data) {
448
519
  && (data.media_display_names === null || typeof data.media_display_names !== 'object')) {
449
520
  throw new Error('CapGraphRenderer strand mode: data.media_display_names must be an object when present');
450
521
  }
522
+ if (data.source_display !== undefined && typeof data.source_display !== 'string') {
523
+ throw new Error('CapGraphRenderer strand mode: data.source_display must be a string when present');
524
+ }
451
525
  }
452
526
 
453
527
  function validateBodyOutcome(outcome, path) {
@@ -499,28 +573,96 @@ function validateRunPayload(data) {
499
573
  }
500
574
  }
501
575
 
502
- function validateMachinePayload(data) {
576
+ function validateEditorGraphPayload(data) {
503
577
  if (!data || typeof data !== 'object') {
504
- throw new Error('CapGraphRenderer machine mode: data must be an object');
578
+ throw new Error('CapGraphRenderer editor-graph mode: data must be an object');
505
579
  }
506
- assertArray(data.elements, 'machine mode data.elements');
580
+ assertArray(data.elements, 'editor-graph mode data.elements');
507
581
  data.elements.forEach((el, idx) => {
508
582
  if (!el || typeof el !== 'object') {
509
- throw new Error(`CapGraphRenderer machine mode: data.elements[${idx}] is not an object`);
583
+ throw new Error(`CapGraphRenderer editor-graph mode: data.elements[${idx}] is not an object`);
510
584
  }
511
585
  if (el.kind !== 'node' && el.kind !== 'cap' && el.kind !== 'edge') {
512
586
  throw new Error(
513
- `CapGraphRenderer machine mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
587
+ `CapGraphRenderer editor-graph mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
514
588
  );
515
589
  }
516
- assertString(el.graph_id, `machine mode data.elements[${idx}].graph_id`);
590
+ assertString(el.graph_id, `editor-graph mode data.elements[${idx}].graph_id`);
517
591
  if (el.kind === 'edge') {
518
- assertString(el.source_graph_id, `machine mode data.elements[${idx}].source_graph_id`);
519
- assertString(el.target_graph_id, `machine mode data.elements[${idx}].target_graph_id`);
592
+ assertString(el.source_graph_id, `editor-graph mode data.elements[${idx}].source_graph_id`);
593
+ assertString(el.target_graph_id, `editor-graph mode data.elements[${idx}].target_graph_id`);
520
594
  }
521
595
  });
522
596
  }
523
597
 
598
+ // `validateResolvedMachinePayload` checks the canonical machine
599
+ // render payload produced by Rust `Machine::to_render_payload_json`.
600
+ // Shape:
601
+ // {
602
+ // "strands": [
603
+ // {
604
+ // "nodes": [{"id": "n0", "urn": "media:pdf"}, ...],
605
+ // "edges": [{
606
+ // "alias": "edge_0",
607
+ // "cap_urn": "...",
608
+ // "is_loop": false,
609
+ // "assignment": [{"cap_arg_media_urn": "media:pdf", "source_node": "n0"}, ...],
610
+ // "target_node": "n1"
611
+ // }, ...],
612
+ // "input_anchor_nodes": ["n0"],
613
+ // "output_anchor_nodes": ["n2"]
614
+ // },
615
+ // ...
616
+ // ]
617
+ // }
618
+ function validateResolvedMachinePayload(data) {
619
+ if (!data || typeof data !== 'object') {
620
+ throw new Error('CapGraphRenderer machine mode: data must be an object');
621
+ }
622
+ assertArray(data.strands, 'machine mode data.strands');
623
+ data.strands.forEach((strand, sIdx) => {
624
+ if (!strand || typeof strand !== 'object') {
625
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}] is not an object`);
626
+ }
627
+ assertArray(strand.nodes, `machine mode data.strands[${sIdx}].nodes`);
628
+ strand.nodes.forEach((n, nIdx) => {
629
+ if (!n || typeof n !== 'object') {
630
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].nodes[${nIdx}] is not an object`);
631
+ }
632
+ assertString(n.id, `machine mode data.strands[${sIdx}].nodes[${nIdx}].id`);
633
+ assertString(n.urn, `machine mode data.strands[${sIdx}].nodes[${nIdx}].urn`);
634
+ });
635
+ assertArray(strand.edges, `machine mode data.strands[${sIdx}].edges`);
636
+ strand.edges.forEach((e, eIdx) => {
637
+ if (!e || typeof e !== 'object') {
638
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}] is not an object`);
639
+ }
640
+ assertString(e.alias, `machine mode data.strands[${sIdx}].edges[${eIdx}].alias`);
641
+ assertString(e.cap_urn, `machine mode data.strands[${sIdx}].edges[${eIdx}].cap_urn`);
642
+ if (typeof e.is_loop !== 'boolean') {
643
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}].is_loop must be boolean`);
644
+ }
645
+ assertArray(e.assignment, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment`);
646
+ e.assignment.forEach((b, bIdx) => {
647
+ if (!b || typeof b !== 'object') {
648
+ throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}] is not an object`);
649
+ }
650
+ assertString(b.cap_arg_media_urn, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}].cap_arg_media_urn`);
651
+ assertString(b.source_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}].source_node`);
652
+ });
653
+ assertString(e.target_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].target_node`);
654
+ });
655
+ assertArray(strand.input_anchor_nodes, `machine mode data.strands[${sIdx}].input_anchor_nodes`);
656
+ strand.input_anchor_nodes.forEach((id, iIdx) => {
657
+ assertString(id, `machine mode data.strands[${sIdx}].input_anchor_nodes[${iIdx}]`);
658
+ });
659
+ assertArray(strand.output_anchor_nodes, `machine mode data.strands[${sIdx}].output_anchor_nodes`);
660
+ strand.output_anchor_nodes.forEach((id, oIdx) => {
661
+ assertString(id, `machine mode data.strands[${sIdx}].output_anchor_nodes[${oIdx}]`);
662
+ });
663
+ });
664
+ }
665
+
524
666
  // =============================================================================
525
667
  // Per-mode graph builders. Each returns the cytoscape `elements` list plus
526
668
  // any mode-specific bookkeeping stored on the renderer instance.
@@ -759,27 +901,9 @@ function buildStrandGraphData(data) {
759
901
  // terminology). Render-time collapse uses this to relabel the
760
902
  // edge with the cap title + (1→n) marker. Defaults to false.
761
903
  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
904
  });
768
905
  edgeCounter++;
769
906
  }
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
- }
783
907
 
784
908
  // Entry node — the strand's source media spec.
785
909
  const inputSlotId = 'input_slot';
@@ -892,15 +1016,6 @@ function buildStrandGraphData(data) {
892
1016
  addNode(nodeId, 'collect', '', 'strand-collect');
893
1017
  addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
894
1018
 
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
1019
  insideForEachBody = null;
905
1020
  bodyEntry = null;
906
1021
  bodyExit = null;
@@ -923,10 +1038,10 @@ function buildStrandGraphData(data) {
923
1038
  });
924
1039
 
925
1040
  // 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.
1041
+ // An unclosed ForEach with a body just creates the synthetic
1042
+ // ForEach node + iteration edge and leaves `prev_node_id` at
1043
+ // the last body cap. The render collapse drops the ForEach node;
1044
+ // the cap edges inside the body keep their own labels verbatim.
930
1045
  if (insideForEachBody !== null) {
931
1046
  const outer = insideForEachBody;
932
1047
  const hasBodyEntry = bodyEntry !== null;
@@ -974,21 +1089,18 @@ function buildStrandGraphData(data) {
974
1089
  //
975
1090
  // 2. The first cap edge entering a ForEach body is relabeled to
976
1091
  // `<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.
1092
+ // `foreachEntry: true`. The collapse does NOT relabel this
1093
+ // edge — the cap's own cardinality marker (from its
1094
+ // `input_is_sequence`/`output_is_sequence`) is the single
1095
+ // source of truth. A `(1→n)` marker on a strand edge means
1096
+ // the cap on that edge produces a sequence, and that's
1097
+ // already reflected in the builder's label.
980
1098
  //
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.
1099
+ // 3. Every Collect (whether closing a body or standalone) is
1100
+ // replaced by a plain unlabeled bridging edge from the
1101
+ // body-exit node to the post-collect target. Any cardinality
1102
+ // shift introduced by the collect is already visible on the
1103
+ // post-collect cap's `input_is_sequence=true` flag.
992
1104
  //
993
1105
  // 4. If the last cap step's `to_spec` is semantically equivalent
994
1106
  // to the strand's `target_spec` (via MediaUrn.isEquivalent),
@@ -997,40 +1109,20 @@ function buildStrandGraphData(data) {
997
1109
  // duplicate node.
998
1110
  function collapseStrandShapeTransitions(built) {
999
1111
  const MediaUrn = requireHostDependency('MediaUrn');
1000
- const foreachCardinality = cardinalityLabel(false, true); // "1→n"
1001
- const collectCardinality = cardinalityLabel(true, false); // "n→1"
1002
1112
 
1003
1113
  // Index for lookups.
1004
1114
  const nodeById = new Map();
1005
1115
  for (const n of built.nodes) nodeById.set(n.id, n);
1006
1116
 
1007
- // Step 1: before dropping Collect nodes, synthesize the
1117
+ // Step 1: before dropping Collect nodes, synthesize a plain
1008
1118
  // bridging edge that replaces each dropped Collect. For a
1009
1119
  // Collect node C the plan builder produced:
1010
1120
  // body_exit → C (strand-collection, label="collect")
1011
1121
  // 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.
1122
+ // The collapse drops C and its two touching edges. We emit an
1123
+ // unlabeled `body_exit next` cap edge in their place. The
1124
+ // Collect transition itself is invisible any cardinality
1125
+ // shift is already on the post-collect cap's own label.
1034
1126
  const synthesizedExitEdges = [];
1035
1127
  for (const node of built.nodes) {
1036
1128
  if (node.nodeClass !== 'strand-collect') continue;
@@ -1043,69 +1135,22 @@ function collapseStrandShapeTransitions(built) {
1043
1135
  const bodyExitNodeId = inEdge.source;
1044
1136
  const bodyExitCapEdge = built.edges.find(e =>
1045
1137
  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
- }
1138
+ // The Collect transition is invisible in the render.
1139
+ // Every Collect becomes a plain unlabeled bridge edge
1140
+ // from the body-exit node to the post-collect target.
1141
+ // Any cardinality shift is already visible on the
1142
+ // body-exit cap's own label (via its input/output
1143
+ // sequence flags) or on the post-collect cap's label.
1098
1144
  synthesizedExitEdges.push({
1099
1145
  id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
1100
1146
  source: bodyExitNodeId,
1101
1147
  target: outEdge.target,
1102
- label: synthLabel,
1103
- title: synthTitle,
1104
- fullUrn: synthFullUrn,
1148
+ label: '',
1149
+ title: '',
1150
+ fullUrn: '',
1105
1151
  edgeClass: 'strand-cap-edge',
1106
1152
  color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
1107
1153
  foreachEntry: false,
1108
- singleCapClosedBody: false,
1109
1154
  });
1110
1155
  }
1111
1156
  }
@@ -1128,29 +1173,15 @@ function collapseStrandShapeTransitions(built) {
1128
1173
  e.edgeClass !== 'strand-collection');
1129
1174
  edges = edges.concat(synthesizedExitEdges);
1130
1175
 
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
- });
1176
+ // Step 2: foreach-entry cap edges keep whatever label the
1177
+ // strand builder emitted — the cap's own cardinality marker
1178
+ // (from input_is_sequence/output_is_sequence) is the single
1179
+ // source of truth for which edge carries a (1→n) / (n→1) /
1180
+ // (n→n) annotation. The ForEach/Collect shape transitions
1181
+ // themselves are invisible in the render; the cap preceding a
1182
+ // ForEach (with output_is_sequence=true) already produces the
1183
+ // (1→n) marker, and the cap following a Collect (with
1184
+ // input_is_sequence=true) produces (n→1). No relabeling.
1154
1185
 
1155
1186
  // Step 3: merge the trailing `step_N → output` edge when step_N
1156
1187
  // and output represent the same media URN. The strand builder
@@ -1252,74 +1283,21 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
1252
1283
  return -1;
1253
1284
  }
1254
1285
 
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.
1286
+ // Remove backbone cap nodes that will be replaced by per-body
1287
+ // replicas from the collapsed strand backbone. Every step_id in
1288
+ // `dropStepIds` is erased along with its incoming and outgoing
1289
+ // edges. Replicas own the per-body rendering of those nodes.
1261
1290
  //
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
-
1291
+ // The function does NOT try to stitch the backbone back together —
1292
+ // the replicas are responsible for connecting the anchor to the
1293
+ // merge target, and the "no outcomes yet" case is handled by
1294
+ // `buildRunGraphData`'s backbone-drop logic which also removes the
1295
+ // now-dangling target node when there are zero successful replicas.
1296
+ function stripRunBackboneReplicaNodes(built, dropStepIds) {
1297
+ if (dropStepIds.size === 0) return built;
1298
+ const keptNodes = built.nodes.filter(n => !dropStepIds.has(n.id));
1299
+ const keptEdges = built.edges.filter(e =>
1300
+ !dropStepIds.has(e.source) && !dropStepIds.has(e.target));
1323
1301
  return {
1324
1302
  nodes: keptNodes,
1325
1303
  edges: keptEdges,
@@ -1331,19 +1309,34 @@ function stripRunBackboneBodyInterior(built, steps, foreachStepIdx, collectStepI
1331
1309
  function buildRunGraphData(data) {
1332
1310
  validateRunPayload(data);
1333
1311
 
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.
1312
+ // Build the raw strand topology and then apply the strand
1313
+ // collapse so the run backbone inherits the same cosmetic
1314
+ // transform (no ForEach/Collect nodes, fix-up edges for
1315
+ // foreach-entry, merged trailing target).
1338
1316
  const strandInput = Object.assign({}, data.resolved_strand, {
1339
1317
  media_display_names: data.media_display_names,
1340
1318
  });
1341
1319
  const strandBuiltRaw = buildStrandGraphData(strandInput);
1342
- const strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
1320
+ let strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
1321
+
1322
+ // Run mode overrides the input_slot node's label with the
1323
+ // host-supplied `source_display` (runtime input filename).
1324
+ // Strand mode ignores this field so the abstract graph shows
1325
+ // the media spec's title from `media_display_names`.
1326
+ if (typeof data.source_display === 'string' && data.source_display.length > 0) {
1327
+ strandBuiltCollapsed = {
1328
+ nodes: strandBuiltCollapsed.nodes.map(n =>
1329
+ n.id === 'input_slot' ? Object.assign({}, n, { label: data.source_display }) : n),
1330
+ edges: strandBuiltCollapsed.edges,
1331
+ sourceSpec: strandBuiltCollapsed.sourceSpec,
1332
+ targetSpec: strandBuiltCollapsed.targetSpec,
1333
+ };
1334
+ }
1343
1335
 
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.
1336
+ // Locate the ForEach/Collect span in the raw steps. Positional
1337
+ // IDs survive the collapse (node IDs are `step_${i}` from the
1338
+ // builder), so we can still identify which collapsed nodes
1339
+ // correspond to body-interior caps.
1347
1340
  const steps = data.resolved_strand.steps;
1348
1341
  let foreachStepIdx = -1;
1349
1342
  let collectStepIdx = -1;
@@ -1353,15 +1346,7 @@ function buildRunGraphData(data) {
1353
1346
  if (variant === 'Collect' && collectStepIdx < 0) collectStepIdx = i;
1354
1347
  }
1355
1348
  const hasForeach = foreachStepIdx >= 0;
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;
1349
+ const hasCollect = collectStepIdx >= 0;
1365
1350
 
1366
1351
  // Filter and bound the outcomes.
1367
1352
  const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
@@ -1371,97 +1356,180 @@ function buildRunGraphData(data) {
1371
1356
  const visibleFailure = failures.slice(0, data.visible_failure_count);
1372
1357
  const hiddenSuccessCount = successes.length - visibleSuccess.length;
1373
1358
  const hiddenFailureCount = failures.length - visibleFailure.length;
1359
+ const visibleOutcomes = visibleSuccess.concat(visibleFailure);
1374
1360
 
1375
- // Collect the Cap steps inside the ForEach body. Each body replica
1376
- // chains through these caps.
1361
+ // Per-body replicas only fire when there's a ForEach AND at
1362
+ // least one visible outcome. Without outcomes, the strand
1363
+ // backbone renders the "plan preview" unchanged.
1364
+ const shouldExpand = hasForeach && visibleOutcomes.length > 0;
1365
+
1366
+ // Body-interior Cap steps — each body iteration chains through
1367
+ // these caps once. Only valid when `shouldExpand` is true.
1377
1368
  const bodyCapSteps = [];
1378
- const bodyStart = hasForeach ? foreachStepIdx + 1 : 0;
1379
- const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
1380
- for (let i = bodyStart; i < bodyEnd; i++) {
1381
- if (Object.keys(steps[i].step_type)[0] === 'Cap') {
1382
- bodyCapSteps.push({ globalIndex: i, step: steps[i] });
1369
+ if (hasForeach) {
1370
+ const bodyStart = foreachStepIdx + 1;
1371
+ const bodyEnd = hasCollect ? collectStepIdx : steps.length;
1372
+ for (let i = bodyStart; i < bodyEnd; i++) {
1373
+ if (Object.keys(steps[i].step_type)[0] === 'Cap') {
1374
+ bodyCapSteps.push({ globalIndex: i, step: steps[i] });
1375
+ }
1383
1376
  }
1384
1377
  }
1385
1378
 
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.
1379
+ // Short-circuit: no outcomes the backbone IS the render.
1380
+ // `anchorNodeId` and `mergeNodeId` are unused in this path.
1381
+ if (!shouldExpand) {
1382
+ return {
1383
+ strandBuilt: strandBuiltCollapsed,
1384
+ replicaNodes: [],
1385
+ replicaEdges: [],
1386
+ showMoreNodes: [],
1387
+ totals: {
1388
+ hiddenSuccessCount,
1389
+ hiddenFailureCount,
1390
+ totalBodyCount: data.total_body_count,
1391
+ visibleSuccessCount: visibleSuccess.length,
1392
+ visibleFailureCount: visibleFailure.length,
1393
+ },
1394
+ };
1395
+ }
1396
+
1397
+ // Expanding. The plan mirrors the working
1398
+ // `machfab-mac/.scrap/working_graph/.../RunGraphViewer.html`:
1392
1399
  //
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';
1400
+ // pre-foreach backbone ... [anchor]
1401
+ //
1402
+ // (Disbind PDF Into Pages, fork)
1403
+ //
1404
+ // body_N_entry (per-body page, e.g. "page_3")
1405
+ // (Make a Decision)
1406
+ //
1407
+ // body_N_step_0 (per-body Decision output)
1408
+ // │
1409
+ // ▼ (if Collect exists) → shared collect target
1410
+ // (else terminal leaf)
1411
+ //
1412
+ // If the cap immediately before the ForEach has
1413
+ // `output_is_sequence=true`, THAT cap is the fan-out point:
1414
+ // its output IS the sequence the ForEach iterates. Its
1415
+ // backbone output node is dropped (replicas own the per-body
1416
+ // rendering), and its step becomes the "fork cap" whose title
1417
+ // labels the anchor→entry edge on the first body.
1418
+ //
1419
+ // Without such a preceding sequence cap, the source itself is
1420
+ // already a list (e.g. `media:pdf;list` source_spec) and the
1421
+ // ForEach iterates it directly.
1422
+ let seqProducerStepIdx = -1;
1423
+ let seqProducerStep = null;
1424
+ if (hasForeach && foreachStepIdx > 0) {
1425
+ const preStep = steps[foreachStepIdx - 1];
1426
+ if (Object.keys(preStep.step_type)[0] === 'Cap'
1427
+ && preStep.step_type.Cap.output_is_sequence === true) {
1428
+ seqProducerStepIdx = foreachStepIdx - 1;
1429
+ seqProducerStep = preStep;
1430
+ }
1431
+ }
1432
+
1433
+ // The anchor is the node BEFORE the sequence producer (or
1434
+ // before the ForEach if there is none). Every body iteration
1435
+ // shares this node as its starting point; per-body entries fan
1436
+ // out from here.
1437
+ let anchorNodeId;
1438
+ if (seqProducerStepIdx >= 0) {
1439
+ anchorNodeId = seqProducerStepIdx > 0
1440
+ ? `step_${seqProducerStepIdx - 1}`
1441
+ : 'input_slot';
1442
+ } else {
1443
+ anchorNodeId = foreachStepIdx > 0
1444
+ ? `step_${foreachStepIdx - 1}`
1445
+ : 'input_slot';
1446
+ }
1447
+
1448
+ // Strip nodes whose per-body replicas will replace them:
1449
+ // the sequence producer's backbone output (if any) AND every
1450
+ // body-interior cap. The foreach-entry edge is dropped
1451
+ // automatically because its source/target is in this set.
1452
+ const dropStepIds = new Set(bodyCapSteps.map(b => `step_${b.globalIndex}`));
1453
+ if (seqProducerStepIdx >= 0) {
1454
+ dropStepIds.add(`step_${seqProducerStepIdx}`);
1455
+ }
1456
+ let strandBuilt = stripRunBackboneReplicaNodes(strandBuiltCollapsed, dropStepIds);
1457
+
1458
+ // If there's a Collect, find the post-collect target in the
1459
+ // backbone — the node successful replicas merge into. This is
1460
+ // the first node reachable forward from the last body cap that
1461
+ // isn't itself a body cap. Walk the pre-strip backbone so we
1462
+ // can cross over the now-dropped body cap nodes.
1463
+ let collectTargetId = null;
1464
+ if (hasCollect && bodyCapSteps.length > 0) {
1465
+ const lastBodyStepId = `step_${bodyCapSteps[bodyCapSteps.length - 1].globalIndex}`;
1466
+ let cursor = lastBodyStepId;
1467
+ let guard = 64;
1468
+ while (guard-- > 0) {
1469
+ const out = strandBuiltCollapsed.edges.find(e => e.source === cursor);
1470
+ if (!out) break;
1471
+ cursor = out.target;
1472
+ if (!dropStepIds.has(cursor)) {
1473
+ collectTargetId = cursor;
1474
+ break;
1459
1475
  }
1460
1476
  }
1477
+ // If the last-body-step is itself the merged terminal (no
1478
+ // outgoing edge), there's no separate collect target —
1479
+ // replicas simply don't merge and the terminal node stays
1480
+ // dropped.
1461
1481
  }
1462
1482
 
1463
1483
  const replicaNodes = [];
1464
1484
  const replicaEdges = [];
1485
+ let replicasBuiltCount = 0;
1486
+
1487
+ // Look up a display name for a media URN via the host-supplied
1488
+ // `media_display_names` map. Uses `MediaUrn.isEquivalent` for
1489
+ // semantic URN equality.
1490
+ const MediaUrn = requireHostDependency('MediaUrn');
1491
+ const mediaDisplayNames = data.media_display_names || {};
1492
+ const displayEntries = [];
1493
+ for (const [urn, display] of Object.entries(mediaDisplayNames)) {
1494
+ if (typeof display !== 'string' || display.length === 0) continue;
1495
+ try {
1496
+ displayEntries.push({ media: MediaUrn.fromString(urn), display });
1497
+ } catch (_) { /* ignore malformed keys */ }
1498
+ }
1499
+ function displayNameFor(canonicalUrn) {
1500
+ const candidate = MediaUrn.fromString(canonicalUrn);
1501
+ for (const entry of displayEntries) {
1502
+ if (candidate.isEquivalent(entry.media)) return entry.display;
1503
+ }
1504
+ return mediaNodeLabel(canonicalUrn);
1505
+ }
1506
+
1507
+ // The per-body "entry" node represents one item of the
1508
+ // sequence being iterated. Its URN is:
1509
+ // * the sequence producer cap's `to_spec` (if such a cap
1510
+ // precedes the ForEach) — this is what Disbind produces,
1511
+ // one-per-body.
1512
+ // * otherwise the ForEach step's own `to_spec` — used when
1513
+ // the source spec is already a list.
1514
+ // Its label defaults to the display name of that URN and is
1515
+ // overridden by the host's per-body `outcome.title` (e.g.
1516
+ // "page_3") when provided.
1517
+ const entryUrn = seqProducerStep
1518
+ ? canonicalMediaUrn(seqProducerStep.to_spec)
1519
+ : canonicalMediaUrn(steps[foreachStepIdx].to_spec);
1520
+ const entryDefaultLabel = displayNameFor(entryUrn);
1521
+
1522
+ // Label shown on the anchor → first-body-entry edge. When the
1523
+ // fan-out is caused by a sequence-producing cap, its title
1524
+ // (e.g. "Disbind PDF Into Pages") labels that edge. The label
1525
+ // appears on the FIRST body's fork edge only to avoid N copies
1526
+ // of the same title cluttering the fan.
1527
+ const forkEdgeTitle = seqProducerStep
1528
+ ? seqProducerStep.step_type.Cap.title
1529
+ : '';
1530
+ const forkEdgeFullUrn = seqProducerStep
1531
+ ? seqProducerStep.step_type.Cap.cap_urn
1532
+ : '';
1465
1533
 
1466
1534
  function buildBodyReplica(outcome) {
1467
1535
  const success = outcome.success;
@@ -1469,7 +1537,7 @@ function buildRunGraphData(data) {
1469
1537
  const edgeClass = success ? 'body-success' : 'body-failure';
1470
1538
  const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
1471
1539
 
1472
- // Trace end: failures stop at failed_cap. `CapUrn.isEquivalent`
1540
+ // Trace end: failures stop at `failed_cap`. `CapUrn.isEquivalent`
1473
1541
  // is used for the match — never string equality.
1474
1542
  let traceEnd = bodyCapSteps.length;
1475
1543
  if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
@@ -1483,13 +1551,48 @@ function buildRunGraphData(data) {
1483
1551
  }
1484
1552
  }
1485
1553
  }
1486
- if (traceEnd === 0) return;
1487
1554
 
1488
- let prevBodyNodeId = anchorNodeId;
1489
1555
  const bodyKey = `body-${outcome.body_index}`;
1490
1556
  const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
1491
1557
  ? outcome.title
1492
- : `body ${outcome.body_index}`;
1558
+ : entryDefaultLabel;
1559
+
1560
+ // Per-body entry node: one item from the iterated sequence.
1561
+ const entryNodeId = `${bodyKey}-entry`;
1562
+ replicaNodes.push({
1563
+ group: 'nodes',
1564
+ data: {
1565
+ id: entryNodeId,
1566
+ label: titleLabel,
1567
+ fullUrn: entryUrn,
1568
+ bodyIndex: outcome.body_index,
1569
+ bodyTitle: titleLabel,
1570
+ },
1571
+ classes: successClass,
1572
+ });
1573
+
1574
+ // Fan-out edge from the pre-foreach anchor to this body's
1575
+ // entry. The fork label (cap title) shows on the FIRST body
1576
+ // only to avoid N copies of the same title cluttering the
1577
+ // fan. The `title` tooltip always exposes the cap title.
1578
+ const isFirstReplica = replicasBuiltCount === 0;
1579
+ const forkLabel = (forkEdgeTitle && isFirstReplica) ? forkEdgeTitle : '';
1580
+ replicaEdges.push({
1581
+ group: 'edges',
1582
+ data: {
1583
+ id: `${bodyKey}-fork`,
1584
+ source: anchorNodeId,
1585
+ target: entryNodeId,
1586
+ label: forkLabel,
1587
+ title: forkEdgeTitle || `body ${outcome.body_index}`,
1588
+ fullUrn: forkEdgeFullUrn,
1589
+ color: `var(${colorVar})`,
1590
+ bodyIndex: outcome.body_index,
1591
+ },
1592
+ classes: edgeClass,
1593
+ });
1594
+
1595
+ let prevBodyNodeId = entryNodeId;
1493
1596
 
1494
1597
  for (let i = 0; i < traceEnd; i++) {
1495
1598
  const body = bodyCapSteps[i].step.step_type.Cap;
@@ -1499,7 +1602,7 @@ function buildRunGraphData(data) {
1499
1602
  group: 'nodes',
1500
1603
  data: {
1501
1604
  id: replicaNodeId,
1502
- label: mediaNodeLabel(targetCanonical),
1605
+ label: displayNameFor(targetCanonical),
1503
1606
  fullUrn: targetCanonical,
1504
1607
  bodyIndex: outcome.body_index,
1505
1608
  bodyTitle: titleLabel,
@@ -1513,10 +1616,9 @@ function buildRunGraphData(data) {
1513
1616
  source: prevBodyNodeId,
1514
1617
  target: replicaNodeId,
1515
1618
  // 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.
1619
+ // identical across every visible replica and would pile
1620
+ // up as unreadable rotated text across the fan-out. The
1621
+ // hover tooltip exposes the title via `title`.
1520
1622
  label: '',
1521
1623
  title: body.title,
1522
1624
  fullUrn: body.cap_urn,
@@ -1528,17 +1630,18 @@ function buildRunGraphData(data) {
1528
1630
  prevBodyNodeId = replicaNodeId;
1529
1631
  }
1530
1632
 
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) {
1633
+ // Successful bodies merge into the collect target ONLY when a
1634
+ // Collect closes the foreach body. Without a Collect (the
1635
+ // common "unclosed foreach" case in machfab realize_strand),
1636
+ // replicas terminate at their last body step — each body has
1637
+ // its own separate output, no shared merge.
1638
+ if (success && hasCollect && collectTargetId) {
1536
1639
  replicaEdges.push({
1537
1640
  group: 'edges',
1538
1641
  data: {
1539
- id: `${bodyKey}-merge`,
1642
+ id: `${bodyKey}-collect`,
1540
1643
  source: prevBodyNodeId,
1541
- target: mergeNodeId,
1644
+ target: collectTargetId,
1542
1645
  label: '',
1543
1646
  title: 'collect',
1544
1647
  fullUrn: '',
@@ -1548,49 +1651,12 @@ function buildRunGraphData(data) {
1548
1651
  classes: edgeClass,
1549
1652
  });
1550
1653
  }
1551
- }
1552
1654
 
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
- };
1655
+ replicasBuiltCount++;
1592
1656
  }
1593
1657
 
1658
+ visibleOutcomes.forEach((o) => buildBodyReplica(o));
1659
+
1594
1660
  // Build success and failure "show more" nodes when there are hidden
1595
1661
  // outcomes. Anchored at the ForEach node (or input_slot if none).
1596
1662
  const showMoreNodes = [];
@@ -1667,9 +1733,10 @@ function buildRunGraphData(data) {
1667
1733
  }
1668
1734
 
1669
1735
  function runCytoscapeElements(built) {
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.
1736
+ // Run mode's `buildRunGraphData` already applied the strand
1737
+ // collapse to its backbone (and dropped body-interior caps
1738
+ // that are replaced by per-body replicas). Emit the backbone
1739
+ // verbatim — `{ collapse: false }` prevents a second pass.
1673
1740
  const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
1674
1741
  return strandElements
1675
1742
  .concat(built.replicaNodes)
@@ -1679,67 +1746,150 @@ function runCytoscapeElements(built) {
1679
1746
 
1680
1747
  // --------- Machine mode builder ---------------------------------------------
1681
1748
 
1682
- function buildMachineGraphData(data) {
1683
- validateMachinePayload(data);
1684
- const nodes = [];
1685
- const edges = [];
1686
- let capEdgeIdx = 0;
1749
+ // The notation analyzer emits a bipartite graph where each cap
1750
+ // application is a 3-element chain:
1751
+ //
1752
+ // data_node [argument edge] → cap_node → [argument edge] → data_node
1753
+ //
1754
+ // The render collapses that chain into a single labeled data→data
1755
+ // edge carrying the cap's name (and cardinality marker derived
1756
+ // from the source/target data nodes' `is_sequence` flags). Cap
1757
+ // nodes and argument edges are dropped. The result is a clean
1758
+ // abstract-machine view that matches strand mode's rendering
1759
+ // style: one edge per cap application, labeled with the cap
1760
+ // title, with cardinality markers where relevant.
1761
+ function buildEditorGraphData(data) {
1762
+ validateEditorGraphPayload(data);
1763
+
1764
+ // Index elements by kind for quick lookup.
1765
+ const dataNodes = new Map(); // graph_id → element
1766
+ const capNodes = new Map(); // graph_id → element
1767
+ const argEdges = []; // list of edge elements
1687
1768
  for (const el of data.elements) {
1688
1769
  if (el.kind === 'node') {
1689
- nodes.push({
1690
- group: 'nodes',
1691
- data: {
1692
- id: el.graph_id,
1693
- label: el.label || '',
1694
- fullUrn: el.detail || el.label || '',
1695
- tokenId: el.token_id || '',
1696
- kind: 'node',
1697
- },
1698
- classes: 'machine-node',
1699
- });
1770
+ dataNodes.set(el.graph_id, el);
1700
1771
  } else if (el.kind === 'cap') {
1701
- nodes.push({
1702
- group: 'nodes',
1703
- data: {
1704
- id: el.graph_id,
1705
- label: el.label || '',
1706
- fullUrn: el.detail || el.label || '',
1707
- tokenId: el.token_id || '',
1708
- kind: 'cap',
1709
- },
1710
- classes: 'machine-cap' + (el.is_loop ? ' machine-loop' : ''),
1711
- });
1772
+ capNodes.set(el.graph_id, el);
1712
1773
  } else if (el.kind === 'edge') {
1713
- edges.push({
1714
- group: 'edges',
1715
- data: {
1716
- id: el.graph_id,
1717
- source: el.source_graph_id,
1718
- target: el.target_graph_id,
1719
- label: el.label || '',
1720
- title: el.label || '',
1721
- fullUrn: el.detail || '',
1722
- tokenId: el.token_id || '',
1723
- color: el.is_loop
1724
- ? 'var(--graph-node-border-highlighted)'
1725
- : edgeHueColor(capEdgeIdx),
1726
- },
1727
- classes: 'machine-edge' + (el.is_loop ? ' machine-loop' : ''),
1728
- });
1729
- capEdgeIdx++;
1774
+ argEdges.push(el);
1730
1775
  }
1731
1776
  }
1777
+
1778
+ // For each cap, identify its incoming and outgoing argument
1779
+ // edges. Incoming edges connect a data-slot source → this cap.
1780
+ // Outgoing edges connect this cap → a data-slot target.
1781
+ const capIncoming = new Map(); // capId → [argEdge, ...]
1782
+ const capOutgoing = new Map(); // capId → [argEdge, ...]
1783
+ for (const e of argEdges) {
1784
+ if (capNodes.has(e.target_graph_id)) {
1785
+ if (!capIncoming.has(e.target_graph_id)) capIncoming.set(e.target_graph_id, []);
1786
+ capIncoming.get(e.target_graph_id).push(e);
1787
+ }
1788
+ if (capNodes.has(e.source_graph_id)) {
1789
+ if (!capOutgoing.has(e.source_graph_id)) capOutgoing.set(e.source_graph_id, []);
1790
+ capOutgoing.get(e.source_graph_id).push(e);
1791
+ }
1792
+ }
1793
+
1794
+ const nodes = [];
1795
+ const edges = [];
1796
+
1797
+ // Emit the data slot nodes verbatim.
1798
+ for (const el of dataNodes.values()) {
1799
+ nodes.push({
1800
+ group: 'nodes',
1801
+ data: {
1802
+ id: el.graph_id,
1803
+ label: el.label || '',
1804
+ fullUrn: el.detail || el.label || '',
1805
+ tokenId: el.token_id || '',
1806
+ kind: 'node',
1807
+ isSequence: el.is_sequence === true,
1808
+ },
1809
+ classes: 'machine-node',
1810
+ });
1811
+ }
1812
+
1813
+ // Collapse each cap into one labeled edge per (input, output)
1814
+ // data-slot pair. For a simple single-input single-output cap
1815
+ // that's one edge. Multi-arg caps emit one edge per combination,
1816
+ // preserving the argument structure while still using a single
1817
+ // visual target per cap application.
1818
+ let capEdgeIdx = 0;
1819
+ for (const [capId, capEl] of capNodes) {
1820
+ const incoming = capIncoming.get(capId) || [];
1821
+ const outgoing = capOutgoing.get(capId) || [];
1822
+
1823
+ // Degenerate cases: a cap with no incoming or no outgoing
1824
+ // argument edges (e.g. partially-written notation). Render
1825
+ // nothing for it — the user sees a missing edge where they
1826
+ // need to complete the notation.
1827
+ if (incoming.length === 0 || outgoing.length === 0) continue;
1828
+
1829
+ // Cardinality of the cap's visual edge comes from the
1830
+ // source-side and target-side data-slot `is_sequence` flags.
1831
+ // When a cap has multiple inputs/outputs, use the MOST
1832
+ // restrictive reading (any seq input → `is_sequence=true` on
1833
+ // that side). This mirrors how cardinalityLabel collapses
1834
+ // multi-arg caps in the strand/browse builders.
1835
+ const inputIsSequence = incoming.some(e => {
1836
+ const src = dataNodes.get(e.source_graph_id);
1837
+ return src && src.is_sequence === true;
1838
+ });
1839
+ const outputIsSequence = outgoing.some(e => {
1840
+ const tgt = dataNodes.get(e.target_graph_id);
1841
+ return tgt && tgt.is_sequence === true;
1842
+ });
1843
+ const cardinality = cardinalityLabel(inputIsSequence, outputIsSequence);
1844
+ const capTitle = capEl.label || '';
1845
+ const label = cardinality === '1\u21921'
1846
+ ? capTitle
1847
+ : `${capTitle} (${cardinality})`;
1848
+
1849
+ const color = capEl.is_loop
1850
+ ? 'var(--graph-loop-color)'
1851
+ : edgeHueColor(capEdgeIdx);
1852
+ const baseClasses = capEl.is_loop ? 'machine-edge machine-loop' : 'machine-edge';
1853
+
1854
+ // Cartesian over incoming × outgoing. For the common case
1855
+ // of one incoming + one outgoing, that's exactly one emitted
1856
+ // edge.
1857
+ for (const inEdge of incoming) {
1858
+ for (const outEdge of outgoing) {
1859
+ edges.push({
1860
+ group: 'edges',
1861
+ data: {
1862
+ id: `${capId}-${inEdge.graph_id}-${outEdge.graph_id}`,
1863
+ source: inEdge.source_graph_id,
1864
+ target: outEdge.target_graph_id,
1865
+ label,
1866
+ title: capTitle,
1867
+ fullUrn: capEl.linked_cap_urn || capEl.detail || '',
1868
+ // The cap node's token id is what the editor uses
1869
+ // for cross-highlighting; prefer that over the arg
1870
+ // edge token ids so clicking the rendered edge
1871
+ // selects the cap in the source text.
1872
+ tokenId: capEl.token_id || '',
1873
+ color: color,
1874
+ },
1875
+ classes: baseClasses,
1876
+ });
1877
+ }
1878
+ }
1879
+ capEdgeIdx++;
1880
+ }
1881
+
1732
1882
  return { nodes, edges };
1733
1883
  }
1734
1884
 
1735
- function machineCytoscapeElements(built) {
1885
+ function editorGraphCytoscapeElements(built) {
1736
1886
  return built.nodes.concat(built.edges);
1737
1887
  }
1738
1888
 
1739
- // A cheap signature for machine-mode inputs. The editor streams updates
1740
- // on every keystroke; we skip the expensive rebuild when the element
1741
- // shape is unchanged.
1742
- function machineGraphSignature(data) {
1889
+ // A cheap signature for editor-graph mode inputs. The editor streams
1890
+ // updates on every keystroke; we skip the expensive rebuild when the
1891
+ // element shape is unchanged.
1892
+ function editorGraphSignature(data) {
1743
1893
  if (!data || !Array.isArray(data.elements)) return '';
1744
1894
  const parts = [];
1745
1895
  for (const el of data.elements) {
@@ -1748,6 +1898,110 @@ function machineGraphSignature(data) {
1748
1898
  return parts.join(';');
1749
1899
  }
1750
1900
 
1901
+ // `buildResolvedMachineGraphData` consumes the canonical
1902
+ // `Machine::to_render_payload_json` shape and produces the cytoscape
1903
+ // elements list directly. Each strand contributes its own nodes and
1904
+ // edges to the same graph; cytoscape lays disconnected components
1905
+ // side by side.
1906
+ //
1907
+ // Node ids and edge aliases are globally unique across all strands
1908
+ // (the Rust serializer assigns them from a single global counter),
1909
+ // so we can pass them straight through to cytoscape without name
1910
+ // collisions.
1911
+ //
1912
+ // Each cap edge is rendered as one cytoscape edge per
1913
+ // (assignment.source_node, target_node) pair. The common
1914
+ // single-input cap produces exactly one rendered edge; multi-input
1915
+ // (fan-in) caps produce one edge per source-arg slot, all sharing
1916
+ // the cap title and color so they read as a single fan-in.
1917
+ function buildResolvedMachineGraphData(data) {
1918
+ validateResolvedMachinePayload(data);
1919
+
1920
+ const nodes = [];
1921
+ const edges = [];
1922
+ const seenNodeIds = new Set();
1923
+ let edgeCounter = 0;
1924
+
1925
+ data.strands.forEach((strand, strandIdx) => {
1926
+ const inputAnchorSet = new Set(strand.input_anchor_nodes);
1927
+ const outputAnchorSet = new Set(strand.output_anchor_nodes);
1928
+
1929
+ for (const node of strand.nodes) {
1930
+ // Each node id is unique across the whole machine; the
1931
+ // Rust side guarantees this via its global counter. If a
1932
+ // duplicate appears it indicates a malformed payload, so
1933
+ // fail hard rather than silently dropping.
1934
+ if (seenNodeIds.has(node.id)) {
1935
+ throw new Error(
1936
+ `CapGraphRenderer machine mode: duplicate node id "${node.id}" in strand ${strandIdx}`
1937
+ );
1938
+ }
1939
+ seenNodeIds.add(node.id);
1940
+
1941
+ const nodeClasses = ['machine-node'];
1942
+ if (inputAnchorSet.has(node.id)) nodeClasses.push('strand-source');
1943
+ if (outputAnchorSet.has(node.id)) nodeClasses.push('strand-target');
1944
+
1945
+ nodes.push({
1946
+ group: 'nodes',
1947
+ data: {
1948
+ id: node.id,
1949
+ label: mediaNodeLabel(canonicalMediaUrn(node.urn)),
1950
+ fullUrn: node.urn,
1951
+ strandIndex: strandIdx,
1952
+ },
1953
+ classes: nodeClasses.join(' '),
1954
+ });
1955
+ }
1956
+
1957
+ for (const edge of strand.edges) {
1958
+ const cardinality = edge.is_loop ? 'n\u21921' : '1\u21921';
1959
+ const capUrn = edge.cap_urn;
1960
+ // Title falls back to the canonical cap URN. The host can
1961
+ // pre-resolve a friendlier title via `step_titles` on the
1962
+ // proto message and use it elsewhere in the UI; the graph
1963
+ // renderer is intentionally registry-free.
1964
+ const capTitle = capUrn;
1965
+ const label = cardinality === '1\u21921'
1966
+ ? edge.alias
1967
+ : `${edge.alias} (${cardinality})`;
1968
+
1969
+ const color = edge.is_loop
1970
+ ? 'var(--graph-loop-color)'
1971
+ : edgeHueColor(edgeCounter);
1972
+ const baseClasses = edge.is_loop
1973
+ ? 'machine-edge machine-loop'
1974
+ : 'machine-edge';
1975
+
1976
+ for (const binding of edge.assignment) {
1977
+ edges.push({
1978
+ group: 'edges',
1979
+ data: {
1980
+ id: `machine-edge-${edgeCounter}`,
1981
+ source: binding.source_node,
1982
+ target: edge.target_node,
1983
+ label,
1984
+ title: `${capTitle} (${binding.cap_arg_media_urn})`,
1985
+ fullUrn: capUrn,
1986
+ color,
1987
+ strandIndex: strandIdx,
1988
+ capArgMediaUrn: binding.cap_arg_media_urn,
1989
+ isLoop: edge.is_loop,
1990
+ },
1991
+ classes: baseClasses,
1992
+ });
1993
+ edgeCounter++;
1994
+ }
1995
+ }
1996
+ });
1997
+
1998
+ return { nodes, edges };
1999
+ }
2000
+
2001
+ function resolvedMachineCytoscapeElements(built) {
2002
+ return built.nodes.concat(built.edges);
2003
+ }
2004
+
1751
2005
  // =============================================================================
1752
2006
  // Renderer class.
1753
2007
  // =============================================================================
@@ -1761,9 +2015,9 @@ class CapGraphRenderer {
1761
2015
  throw new Error('CapGraphRenderer: options must be an object');
1762
2016
  }
1763
2017
  const mode = options.mode;
1764
- if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine') {
2018
+ if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine' && mode !== 'editor-graph') {
1765
2019
  throw new Error(
1766
- `CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine" (got ${JSON.stringify(mode)})`
2020
+ `CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine", "editor-graph" (got ${JSON.stringify(mode)})`
1767
2021
  );
1768
2022
  }
1769
2023
 
@@ -1827,8 +2081,12 @@ class CapGraphRenderer {
1827
2081
  this._strandBuilt = null;
1828
2082
  this._runBuilt = null;
1829
2083
 
1830
- // Machine state.
1831
- this._machineSignature = null;
2084
+ // Editor-graph state (Monaco machine-notation editor live view).
2085
+ this._editorGraphSignature = null;
2086
+ this._editorGraphBuilt = null;
2087
+
2088
+ // Resolved-machine state (canonical Machine render payload from
2089
+ // Rust `Machine::to_render_payload_json`).
1832
2090
  this._machineBuilt = null;
1833
2091
 
1834
2092
  // Theme observer.
@@ -1880,15 +2138,19 @@ class CapGraphRenderer {
1880
2138
  this._runBuilt = buildRunGraphData(data);
1881
2139
  return this;
1882
2140
  }
1883
- if (this.mode === 'machine') {
1884
- const signature = machineGraphSignature(data);
1885
- if (signature === this._machineSignature && this.cy) {
2141
+ if (this.mode === 'editor-graph') {
2142
+ const signature = editorGraphSignature(data);
2143
+ if (signature === this._editorGraphSignature && this.cy) {
1886
2144
  // Same shape — restyle for theme changes and return.
1887
2145
  this.cy.style(buildStylesheet());
1888
2146
  return this;
1889
2147
  }
1890
- this._machineSignature = signature;
1891
- this._machineBuilt = buildMachineGraphData(data);
2148
+ this._editorGraphSignature = signature;
2149
+ this._editorGraphBuilt = buildEditorGraphData(data);
2150
+ return this;
2151
+ }
2152
+ if (this.mode === 'machine') {
2153
+ this._machineBuilt = buildResolvedMachineGraphData(data);
1892
2154
  return this;
1893
2155
  }
1894
2156
  throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
@@ -1956,7 +2218,7 @@ class CapGraphRenderer {
1956
2218
  maxZoom: 10,
1957
2219
  wheelSensitivity: 0.3,
1958
2220
  boxSelectionEnabled: false,
1959
- autounselectify: this.mode === 'machine',
2221
+ autounselectify: this.mode === 'editor-graph' || this.mode === 'machine',
1960
2222
  });
1961
2223
 
1962
2224
  const resizeAndRefit = () => {
@@ -1989,9 +2251,13 @@ class CapGraphRenderer {
1989
2251
  if (!this._runBuilt) return [];
1990
2252
  return runCytoscapeElements(this._runBuilt);
1991
2253
  }
2254
+ if (this.mode === 'editor-graph') {
2255
+ if (!this._editorGraphBuilt) return [];
2256
+ return editorGraphCytoscapeElements(this._editorGraphBuilt);
2257
+ }
1992
2258
  if (this.mode === 'machine') {
1993
2259
  if (!this._machineBuilt) return [];
1994
- return machineCytoscapeElements(this._machineBuilt);
2260
+ return resolvedMachineCytoscapeElements(this._machineBuilt);
1995
2261
  }
1996
2262
  throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
1997
2263
  }
@@ -2252,12 +2518,13 @@ class CapGraphRenderer {
2252
2518
  }
2253
2519
 
2254
2520
  // ===========================================================================
2255
- // Machine mode API — used by the editor for cross-highlight.
2521
+ // Editor-graph mode API — used by the notation editor for
2522
+ // cross-highlight between source-text spans and graph elements.
2256
2523
  // ===========================================================================
2257
2524
 
2258
- applyMachineActiveTokenIds(tokenIds) {
2259
- if (this.mode !== 'machine') {
2260
- throw new Error(`CapGraphRenderer: applyMachineActiveTokenIds is only valid in machine mode (current: ${this.mode})`);
2525
+ applyEditorGraphActiveTokenIds(tokenIds) {
2526
+ if (this.mode !== 'editor-graph') {
2527
+ throw new Error(`CapGraphRenderer: applyEditorGraphActiveTokenIds is only valid in editor-graph mode (current: ${this.mode})`);
2261
2528
  }
2262
2529
  if (!this.cy) return;
2263
2530
  const wanted = new Set(tokenIds || []);
@@ -2575,11 +2842,13 @@ if (typeof module !== 'undefined' && module.exports) {
2575
2842
  buildStrandGraphData,
2576
2843
  collapseStrandShapeTransitions,
2577
2844
  buildRunGraphData,
2578
- buildMachineGraphData,
2845
+ buildEditorGraphData,
2846
+ buildResolvedMachineGraphData,
2579
2847
  classifyStrandCapSteps,
2580
2848
  validateStrandPayload,
2581
2849
  validateRunPayload,
2582
- validateMachinePayload,
2850
+ validateEditorGraphPayload,
2851
+ validateResolvedMachinePayload,
2583
2852
  validateStrandStep,
2584
2853
  validateBodyOutcome,
2585
2854
  };