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.
package/capdag.test.js CHANGED
@@ -3755,6 +3755,7 @@ const {
3755
3755
  canonicalMediaUrn: rendererCanonicalMediaUrn,
3756
3756
  mediaNodeLabel: rendererMediaNodeLabel,
3757
3757
  buildStrandGraphData: rendererBuildStrandGraphData,
3758
+ collapseStrandShapeTransitions: rendererCollapseStrandShapeTransitions,
3758
3759
  buildRunGraphData: rendererBuildRunGraphData,
3759
3760
  buildMachineGraphData: rendererBuildMachineGraphData,
3760
3761
  classifyStrandCapSteps: rendererClassifyStrandCapSteps,
@@ -4030,86 +4031,441 @@ function testRenderer_classifyStrandCapSteps_nestedForks() {
4030
4031
  assert(!capFlags.get(5).prevForEach && capFlags.get(5).nextCollect, 'cap3 outer exit');
4031
4032
  }
4032
4033
 
4033
- function testRenderer_buildStrandGraphData_labelsForkBoundaries() {
4034
- // End-to-end: a foreach strand in which source_spec (media:pdf;list)
4035
- // differs from the first cap's from_spec (media:pdf) produces an
4036
- // explicit fix-up edge source→firstCap.from labeled "for each". The
4037
- // cap edge carries only the cap title. The topology is
4038
- // media:pdf;list --[for each]--> media:pdf --[extract text]--> media:txt
4039
- // Only two edges ForEach is NOT a separate node, Collect is absent
4040
- // because the strand's target_spec already matches the last cap's
4041
- // to_spec (no fan-in fix-up needed).
4034
+ // Helper: find an edge with the given source/target ids.
4035
+ function findEdge(edges, source, target) {
4036
+ return edges.find(e => e.source === source && e.target === target);
4037
+ }
4038
+
4039
+ function testRenderer_buildStrandGraphData_singleCapPlain() {
4040
+ // Minimal strand with one plain 1→1 cap. Plan builder produces:
4041
+ // input_slot step_0 (cap) output
4042
+ // (two edges, three nodes). No cardinality marker in the cap label
4043
+ // because input_is_sequence == output_is_sequence == false.
4044
+ const payload = {
4045
+ source_spec: 'media:a',
4046
+ target_spec: 'media:b',
4047
+ steps: [
4048
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4049
+ ],
4050
+ };
4051
+ const built = rendererBuildStrandGraphData(payload);
4052
+ const nodeIds = built.nodes.map(n => n.id).sort();
4053
+ assertEqual(JSON.stringify(nodeIds), JSON.stringify(['input_slot', 'output', 'step_0']),
4054
+ 'nodes are input_slot + step_0 + output (positional ids)');
4055
+ assertEqual(built.edges.length, 2, 'two edges: input_slot→step_0 and step_0→output');
4056
+ const capEdge = findEdge(built.edges, 'input_slot', 'step_0');
4057
+ assert(capEdge !== undefined, 'cap edge from input_slot to step_0 exists');
4058
+ assertEqual(capEdge.label, 'x', 'plain cap edge label is the cap title with no cardinality marker');
4059
+ const outEdge = findEdge(built.edges, 'step_0', 'output');
4060
+ assert(outEdge !== undefined, 'output edge from step_0 to output exists');
4061
+ }
4062
+
4063
+ function testRenderer_buildStrandGraphData_sequenceShowsCardinality() {
4064
+ // A cap with input_is_sequence=true MUST emit "(n→1)" on its edge
4065
+ // label.
4066
+ const payload = {
4067
+ source_spec: 'media:a;list',
4068
+ target_spec: 'media:b',
4069
+ steps: [
4070
+ makeCapStep('cap:in="media:a;list";op=x;out="media:b"', 'x', 'media:a;list', 'media:b', true, false),
4071
+ ],
4072
+ };
4073
+ const built = rendererBuildStrandGraphData(payload);
4074
+ const capEdge = findEdge(built.edges, 'input_slot', 'step_0');
4075
+ assert(capEdge !== undefined, 'cap edge exists');
4076
+ assert(capEdge.label.includes('(n\u21921)'),
4077
+ `cap edge label must include (n\u21921) marker; got: ${capEdge.label}`);
4078
+ }
4079
+
4080
+ function testRenderer_buildStrandGraphData_foreachCollectSpan() {
4081
+ // Strand: [ForEach, Cap, Collect]. Plan builder produces:
4082
+ // input_slot (source) →direct→ step_1 (cap) — cap emits its own
4083
+ // direct edge from prev
4084
+ // input_slot →direct→ step_0 (foreach) — created when Collect
4085
+ // step_0 →iteration→ step_1 — iteration edge
4086
+ // step_1 →collection→ step_2 (collect) — collection edge
4087
+ // step_2 →direct→ output — output connector
4088
+ //
4089
+ // (six nodes: input_slot, step_0, step_1, step_2, output; five
4090
+ // edges.) ForEach and Collect are REAL nodes in the graph, not
4091
+ // labels on cap edges — they're distinct processing units in the
4092
+ // plan. This mirrors capdag's plan_builder.rs exactly.
4042
4093
  const payload = {
4043
4094
  source_spec: 'media:pdf;list',
4044
- target_spec: 'media:txt',
4095
+ target_spec: 'media:txt;list',
4045
4096
  steps: [
4046
4097
  makeForEachStep('media:pdf;list'),
4047
- makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract text', 'media:pdf', 'media:txt', false, false),
4098
+ makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
4099
+ makeCollectStep('media:txt'),
4048
4100
  ],
4049
4101
  };
4050
4102
  const built = rendererBuildStrandGraphData(payload);
4051
- assertEqual(built.edges.length, 2, 'one fix-up edge + one cap edge');
4052
- const forEachEdge = built.edges[0];
4053
- assertEqual(forEachEdge.label, 'for each', 'first edge is the for-each fan-out');
4054
- assertEqual(forEachEdge.source, 'media:pdf;list', 'for-each sources from source_spec');
4055
- assertEqual(forEachEdge.target, 'media:pdf', 'for-each targets first cap.from_spec');
4056
- const capEdge = built.edges[1];
4057
- assertEqual(capEdge.label, 'extract text', 'cap edge carries only the cap title');
4058
- assertEqual(capEdge.source, 'media:pdf', 'cap sources from its from_spec');
4059
- assertEqual(capEdge.target, 'media:txt', 'cap targets its to_spec');
4060
- }
4061
-
4062
- function testRenderer_buildStrandGraphData_collectFixupEdge() {
4063
- // When the strand's target_spec (media:pdf;list) differs from the
4064
- // last cap's to_spec (media:pdf), the builder emits an explicit
4065
- // fix-up edge lastCap.to target labeled "collect".
4103
+ const nodeIds = built.nodes.map(n => n.id).sort();
4104
+ assertEqual(JSON.stringify(nodeIds),
4105
+ JSON.stringify(['input_slot', 'output', 'step_0', 'step_1', 'step_2']),
4106
+ 'positional nodes for source, foreach, cap, collect, output');
4107
+
4108
+ // The five edges the plan builder would produce:
4109
+ assert(findEdge(built.edges, 'input_slot', 'step_1') !== undefined,
4110
+ 'cap direct edge input_slot→step_1 (prev wasn\'t advanced by ForEach)');
4111
+ assert(findEdge(built.edges, 'input_slot', 'step_0') !== undefined,
4112
+ 'foreach input edge input_slot→step_0');
4113
+ assert(findEdge(built.edges, 'step_0', 'step_1') !== undefined,
4114
+ 'iteration edge step_0→step_1 (body entry)');
4115
+ assert(findEdge(built.edges, 'step_1', 'step_2') !== undefined,
4116
+ 'collection edge step_1→step_2 (body exit collect)');
4117
+ assert(findEdge(built.edges, 'step_2', 'output') !== undefined,
4118
+ 'output edge step_2→output');
4119
+
4120
+ // ForEach and Collect nodes carry their canonical labels.
4121
+ const foreachNode = built.nodes.find(n => n.id === 'step_0');
4122
+ assertEqual(foreachNode.label, 'for each', 'ForEach node labeled "for each"');
4123
+ const collectNode = built.nodes.find(n => n.id === 'step_2');
4124
+ assertEqual(collectNode.label, 'collect', 'Collect node labeled "collect"');
4125
+ }
4126
+
4127
+ function testRenderer_buildStrandGraphData_standaloneCollect() {
4128
+ // Strand with a standalone Collect (no enclosing ForEach). Plan
4129
+ // builder creates a Collect node consuming prev directly — plain
4130
+ // direct edge, no iteration/collection semantics.
4066
4131
  const payload = {
4067
4132
  source_spec: 'media:a',
4068
- target_spec: 'media:pdf;list',
4133
+ target_spec: 'media:b;list',
4069
4134
  steps: [
4070
- makeCapStep('cap:in="media:a";op=x;out="media:pdf"', 'x', 'media:a', 'media:pdf', false, false),
4071
- makeCollectStep('media:pdf'),
4135
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4136
+ makeCollectStep('media:b'),
4072
4137
  ],
4073
4138
  };
4074
4139
  const built = rendererBuildStrandGraphData(payload);
4075
- assertEqual(built.edges.length, 2, 'one cap edge + one collect fix-up');
4076
- const capEdge = built.edges[0];
4077
- assertEqual(capEdge.label, 'x', 'cap edge plain title');
4078
- const collectEdge = built.edges[1];
4079
- assertEqual(collectEdge.label, 'collect', 'collect fix-up labeled "collect"');
4080
- assertEqual(collectEdge.source, 'media:pdf', 'collect sources from last cap.to_spec');
4081
- assertEqual(collectEdge.target, 'media:pdf;list', 'collect targets target_spec');
4082
- }
4083
-
4084
- function testRenderer_buildStrandGraphData_plainCapNoMarker() {
4085
- // A plain 1→1 cap step (no ForEach/Collect adjacency, no sequence)
4086
- // must not emit any cardinality marker in the label. This catches a
4087
- // regression where "1→1" was accidentally appended to every edge.
4140
+ assert(findEdge(built.edges, 'input_slot', 'step_0') !== undefined,
4141
+ 'cap edge input_slot → step_0');
4142
+ assert(findEdge(built.edges, 'step_0', 'step_1') !== undefined,
4143
+ 'standalone collect edge step_0 → step_1 (Collect node)');
4144
+ assert(findEdge(built.edges, 'step_1', 'output') !== undefined,
4145
+ 'output edge step_1 output');
4146
+ const collectNode = built.nodes.find(n => n.id === 'step_1');
4147
+ assertEqual(collectNode.label, 'collect', 'Collect node labeled "collect"');
4148
+ }
4149
+
4150
+ function testRenderer_buildStrandGraphData_unclosedForEachBody() {
4151
+ // Strand: [Cap_a, ForEach, Cap_b] with no closing Collect. The plan
4152
+ // builder's "unclosed ForEach" branch creates a ForEach node
4153
+ // connecting Cap_a to Cap_b via iteration, with prev becoming the
4154
+ // body exit (Cap_b).
4088
4155
  const payload = {
4089
4156
  source_spec: 'media:a',
4090
- target_spec: 'media:b',
4157
+ target_spec: 'media:c',
4158
+ steps: [
4159
+ makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4160
+ makeForEachStep('media:b'),
4161
+ makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
4162
+ ],
4163
+ };
4164
+ const built = rendererBuildStrandGraphData(payload);
4165
+ // Cap_a connects from input_slot.
4166
+ assert(findEdge(built.edges, 'input_slot', 'step_0') !== undefined,
4167
+ 'cap_a edge input_slot → step_0');
4168
+ // Cap_b still connects directly from step_0 (the ForEach didn't
4169
+ // advance prev). This mirrors plan_builder.
4170
+ assert(findEdge(built.edges, 'step_0', 'step_2') !== undefined,
4171
+ 'cap_b direct edge step_0 → step_2');
4172
+ // ForEach node at step_1 with direct edge from step_0 and iteration
4173
+ // edge to step_2.
4174
+ assert(findEdge(built.edges, 'step_0', 'step_1') !== undefined,
4175
+ 'foreach input edge step_0 → step_1');
4176
+ assert(findEdge(built.edges, 'step_1', 'step_2') !== undefined,
4177
+ 'iteration edge step_1 → step_2 (body entry)');
4178
+ // Output connects from step_2 (body exit).
4179
+ assert(findEdge(built.edges, 'step_2', 'output') !== undefined,
4180
+ 'output edge step_2 → output');
4181
+ }
4182
+
4183
+ function testRenderer_buildStrandGraphData_nestedForEachThrows() {
4184
+ // Nested ForEach without an intervening body cap in the outer
4185
+ // ForEach is an illegal nesting per plan_builder. The renderer
4186
+ // must throw the same error to surface the issue rather than
4187
+ // render a malformed graph.
4188
+ const payload = {
4189
+ source_spec: 'media:a;list;list',
4190
+ target_spec: 'media:a',
4191
+ steps: [
4192
+ makeForEachStep('media:a;list;list'),
4193
+ makeForEachStep('media:a;list'),
4194
+ makeCapStep('cap:in="media:a";op=x;out="media:a"', 'x', 'media:a', 'media:a', false, false),
4195
+ ],
4196
+ };
4197
+ let threw = false;
4198
+ try {
4199
+ rendererBuildStrandGraphData(payload);
4200
+ } catch (e) {
4201
+ threw = true;
4202
+ assert(e.message.includes('nested ForEach'),
4203
+ 'error must name the nested-ForEach violation');
4204
+ }
4205
+ assert(threw, 'nested ForEach without outer body cap must throw');
4206
+ }
4207
+
4208
+ function testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker() {
4209
+ // User spec: ForEach/Collect are NOT rendered as nodes. The
4210
+ // transition is labeled with the enclosed cap's title + a
4211
+ // cardinality marker. For a single-cap body the marker is n→n
4212
+ // (iterate + collect combined) because the same cap is both
4213
+ // the body entry and the body exit.
4214
+ //
4215
+ // Strand [ForEach, Cap(extract), Collect], source=pdf;list,
4216
+ // target=txt;list — target is NOT equivalent to cap's to_spec
4217
+ // media:txt so the output node is retained.
4218
+ //
4219
+ // Expected render shape: 3 nodes (input_slot, step_1, output),
4220
+ // with the entry edge labeled "extract (n→n)" and a plain
4221
+ // unlabeled connector to the output.
4222
+ const payload = {
4223
+ source_spec: 'media:pdf;list',
4224
+ target_spec: 'media:txt;list',
4225
+ steps: [
4226
+ makeForEachStep('media:pdf;list'),
4227
+ makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
4228
+ makeCollectStep('media:txt'),
4229
+ ],
4230
+ };
4231
+ const built = rendererBuildStrandGraphData(payload);
4232
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4233
+
4234
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4235
+ assertEqual(JSON.stringify(nodeIds),
4236
+ JSON.stringify(['input_slot', 'output', 'step_1']),
4237
+ 'collapse removes the ForEach and Collect nodes; the remaining nodes are source + cap + target');
4238
+
4239
+ // Exactly one edge from input_slot → step_1, carrying the cap
4240
+ // title + iterate+collect cardinality marker.
4241
+ const entryEdges = collapsed.edges.filter(e => e.source === 'input_slot' && e.target === 'step_1');
4242
+ assertEqual(entryEdges.length, 1,
4243
+ 'phantom duplicate cap edge must be gone — exactly one edge from source to cap');
4244
+ assertEqual(entryEdges[0].label, 'extract (n\u2192n)',
4245
+ 'single-cap-body edge is labeled "<cap_title> (n→n)"');
4246
+
4247
+ // The exit side is a plain unlabeled connector — the cap title
4248
+ // is already shown on the entry edge.
4249
+ const exitEdges = collapsed.edges.filter(e => e.source === 'step_1' && e.target === 'output');
4250
+ assertEqual(exitEdges.length, 1,
4251
+ 'there is exactly one exit edge step_1 → output');
4252
+ assertEqual(exitEdges[0].label, '',
4253
+ 'exit connector for a single-cap body is unlabeled (cap title already shown on entry edge)');
4254
+ }
4255
+
4256
+ function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
4257
+ // [Cap_a, ForEach, Cap_b] with no Collect, source=media:a,
4258
+ // target=media:c. Cap_b's to_spec is media:c which is
4259
+ // equivalent to target_spec, so the output node is merged
4260
+ // into step_2.
4261
+ //
4262
+ // Raw topology:
4263
+ // input_slot → step_0 (cap_a) — normal, not in any body
4264
+ // step_0 → step_2 (cap_b, foreachEntry=true — phantom under
4265
+ // plan builder, but the foreach body entry
4266
+ // under the render model)
4267
+ // step_0 → step_1 (foreach direct)
4268
+ // step_1 → step_2 (iteration)
4269
+ // step_2 → output (trailing connector, empty label)
4270
+ //
4271
+ // Collapse:
4272
+ // - step_1 (foreach) removed with its iteration edges.
4273
+ // - step_0 → step_2 relabeled "b (1→n)" via foreachEntry.
4274
+ // - step_2 → output merged because upstream.fullUrn equivalent
4275
+ // to target_spec (both media:c); step_2 takes the target
4276
+ // display label.
4277
+ //
4278
+ // Final: 3 nodes (input_slot, step_0, step_2), 2 edges.
4279
+ const payload = {
4280
+ source_spec: 'media:a',
4281
+ target_spec: 'media:c',
4282
+ steps: [
4283
+ makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
4284
+ makeForEachStep('media:b'),
4285
+ makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
4286
+ ],
4287
+ };
4288
+ const built = rendererBuildStrandGraphData(payload);
4289
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4290
+
4291
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4292
+ assertEqual(JSON.stringify(nodeIds),
4293
+ JSON.stringify(['input_slot', 'step_0', 'step_2']),
4294
+ 'foreach node removed and output merged into step_2 (same URN as target)');
4295
+
4296
+ // Exactly one edge from step_0 to step_2, labeled with cap_b's
4297
+ // title + (1→n) marker.
4298
+ const step0ToStep2 = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
4299
+ assertEqual(step0ToStep2.length, 1,
4300
+ 'exactly one step_0 → step_2 edge after dropping the foreach iteration');
4301
+ assertEqual(step0ToStep2[0].label, 'b (1\u2192n)',
4302
+ 'the foreach-entry edge is labeled "<cap_b_title> (1→n)"');
4303
+
4304
+ // Cap_a's edge is unchanged (not inside a foreach body).
4305
+ const capA = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4306
+ assert(capA !== undefined, 'cap_a edge input_slot → step_0 exists');
4307
+ assertEqual(capA.label, 'a', 'cap_a edge carries just its title (no cardinality marker since 1→1)');
4308
+
4309
+ // After merging, step_2 becomes the render target — no separate
4310
+ // output node exists.
4311
+ const outputNode = collapsed.nodes.find(n => n.id === 'output');
4312
+ assertEqual(outputNode, undefined,
4313
+ 'output node was merged into step_2 because their URNs are semantically equivalent');
4314
+ const mergedTarget = collapsed.nodes.find(n => n.id === 'step_2');
4315
+ assertEqual(mergedTarget.nodeClass, 'strand-target',
4316
+ 'merged step_2 takes on the strand-target role');
4317
+ }
4318
+
4319
+ function testRenderer_collapseStrand_standaloneCollectCollapses() {
4320
+ // [Cap, Collect] with no enclosing ForEach, source=media:a,
4321
+ // target=media:b;list (NOT equivalent to cap's to_spec media:b,
4322
+ // so the output node is retained after collapse).
4323
+ //
4324
+ // Collapse:
4325
+ // - step_1 (standalone Collect) removed.
4326
+ // - Synthesized bridging edge step_0 → output labeled "collect".
4327
+ // - The cap edge input_slot → step_0 is unchanged because the
4328
+ // cap is not inside any foreach body.
4329
+ //
4330
+ // Final: 3 nodes (input_slot, step_0, output), 2 edges.
4331
+ const payload = {
4332
+ source_spec: 'media:a',
4333
+ target_spec: 'media:b;list',
4091
4334
  steps: [
4092
4335
  makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4336
+ makeCollectStep('media:b'),
4093
4337
  ],
4094
4338
  };
4095
4339
  const built = rendererBuildStrandGraphData(payload);
4096
- assertEqual(built.edges.length, 1, 'one edge');
4097
- assertEqual(built.edges[0].label, 'x', 'plain cap has no cardinality suffix');
4098
- }
4099
-
4100
- function testRenderer_buildStrandGraphData_sequenceShowsCardinality() {
4101
- // A cap with input_is_sequence=true MUST emit "(n→1)" on the label.
4340
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4341
+
4342
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4343
+ assertEqual(JSON.stringify(nodeIds),
4344
+ JSON.stringify(['input_slot', 'output', 'step_0']),
4345
+ 'collect node removed; only cap + source + target remain');
4346
+
4347
+ const capEdge = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4348
+ assert(capEdge !== undefined, 'cap edge survives');
4349
+ assertEqual(capEdge.label, 'x',
4350
+ 'cap edge carries just its title — no foreach cardinality markers because the cap is not inside a foreach body');
4351
+
4352
+ const collectEdge = collapsed.edges.find(e => e.source === 'step_0' && e.target === 'output');
4353
+ assert(collectEdge !== undefined, 'step_0 → output edge synthesized by collect collapse');
4354
+ assertEqual(collectEdge.label, 'collect',
4355
+ 'the synthesized bridging edge for a standalone Collect is labeled "collect"');
4356
+ }
4357
+
4358
+ function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
4359
+ // Regression test mirroring the user's real strand:
4360
+ // [Cap_disbind (output_is_sequence=true), ForEach, Cap_make_decision],
4361
+ // source = media:pdf, target = media:decision (equivalent to
4362
+ // the last cap's to_spec).
4363
+ //
4364
+ // Expected render shape after collapse:
4365
+ // input_slot → step_0 labeled "Disbind (1→n)" — from Disbind's
4366
+ // own output_is_sequence flag, computed at build time.
4367
+ // step_0 → step_2 labeled "Make a Decision (1→n)" — because
4368
+ // make_decision is the first cap inside an unclosed
4369
+ // ForEach body (foreachEntry=true).
4370
+ // No separate output node because step_2's to_spec equals the
4371
+ // strand target.
4372
+ //
4373
+ // If this test fails, the runtime bug would manifest as either
4374
+ // (a) a duplicate target node, (b) a "for each" labeled edge
4375
+ // where the cap title should be, or (c) the phantom direct cap
4376
+ // edge not being relabeled.
4102
4377
  const payload = {
4103
- source_spec: 'media:a;list',
4378
+ source_spec: 'media:pdf',
4379
+ target_spec: 'media:decision',
4380
+ steps: [
4381
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4382
+ makeForEachStep('media:page'),
4383
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4384
+ ],
4385
+ };
4386
+ const built = rendererBuildStrandGraphData(payload);
4387
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4388
+
4389
+ const nodeIds = collapsed.nodes.map(n => n.id).sort();
4390
+ assertEqual(JSON.stringify(nodeIds),
4391
+ JSON.stringify(['input_slot', 'step_0', 'step_2']),
4392
+ 'foreach node and duplicate output node both removed');
4393
+
4394
+ // Disbind cap edge carries its own (1→n) marker from
4395
+ // output_is_sequence=true, NOT from the foreach flag.
4396
+ const disbind = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
4397
+ assert(disbind !== undefined, 'Disbind edge input_slot → step_0 exists');
4398
+ assertEqual(disbind.label, 'Disbind (1\u2192n)',
4399
+ 'Disbind edge reflects its own output_is_sequence=true cardinality');
4400
+
4401
+ // make_decision cap edge is the foreach entry — the plan-builder
4402
+ // phantom direct edge becomes the render-visible cap edge with
4403
+ // (1→n) appended to the cap title.
4404
+ const makeDecision = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
4405
+ assertEqual(makeDecision.length, 1,
4406
+ 'exactly one edge from Text Page to Decision (phantom not duplicated)');
4407
+ assertEqual(makeDecision[0].label, 'Make a Decision (1\u2192n)',
4408
+ 'the foreach entry edge is labeled "<cap_title> (1→n)", not "for each"');
4409
+
4410
+ // Duplicate target must be gone.
4411
+ const outputNode = collapsed.nodes.find(n => n.id === 'output');
4412
+ assertEqual(outputNode, undefined,
4413
+ 'output node merged into step_2 because they represent the same URN');
4414
+ }
4415
+
4416
+ function testRenderer_collapseStrand_plainCapMergesTrailingOutput() {
4417
+ // A strand with a single plain 1→1 cap whose to_spec equals
4418
+ // target_spec. The plan-builder topology produces:
4419
+ // input_slot → step_0 (cap) → output
4420
+ // The collapse pass merges the trailing output edge because
4421
+ // step_0 and output represent the same URN (media:b).
4422
+ //
4423
+ // Final: 2 nodes (input_slot, step_0), 1 edge.
4424
+ const payload = {
4425
+ source_spec: 'media:a',
4104
4426
  target_spec: 'media:b',
4105
4427
  steps: [
4106
- makeCapStep('cap:in="media:a;list";op=x;out="media:b"', 'x', 'media:a;list', 'media:b', true, false),
4428
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4107
4429
  ],
4108
4430
  };
4109
4431
  const built = rendererBuildStrandGraphData(payload);
4110
- assertEqual(built.edges.length, 1, 'one edge');
4111
- assert(built.edges[0].label.includes('(n\u21921)'),
4112
- `edge label must include (n\u21921) marker; got: ${built.edges[0].label}`);
4432
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4433
+
4434
+ assertEqual(collapsed.nodes.length, 2,
4435
+ 'duplicate output node merged into step_0 — 2 nodes remain');
4436
+ const outputNode = collapsed.nodes.find(n => n.id === 'output');
4437
+ assertEqual(outputNode, undefined,
4438
+ 'output node dropped by merge');
4439
+ const mergedTarget = collapsed.nodes.find(n => n.id === 'step_0');
4440
+ assertEqual(mergedTarget.nodeClass, 'strand-target',
4441
+ 'step_0 takes on the strand-target role after the merge');
4442
+
4443
+ assertEqual(collapsed.edges.length, 1, 'single cap edge remains');
4444
+ assertEqual(collapsed.edges[0].source, 'input_slot');
4445
+ assertEqual(collapsed.edges[0].target, 'step_0');
4446
+ assertEqual(collapsed.edges[0].label, 'x', 'cap title preserved as edge label');
4447
+ }
4448
+
4449
+ function testRenderer_collapseStrand_plainCapDistinctTargetNoMerge() {
4450
+ // A strand with a single plain cap whose to_spec is NOT
4451
+ // equivalent to target_spec. The output node must be retained
4452
+ // and the trailing connector edge preserved.
4453
+ const payload = {
4454
+ source_spec: 'media:a',
4455
+ target_spec: 'media:b;list',
4456
+ steps: [
4457
+ makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
4458
+ ],
4459
+ };
4460
+ const built = rendererBuildStrandGraphData(payload);
4461
+ const collapsed = rendererCollapseStrandShapeTransitions(built);
4462
+
4463
+ assertEqual(collapsed.nodes.length, 3,
4464
+ 'no merge because cap to_spec (media:b) and target (media:b;list) are semantically distinct');
4465
+ assert(collapsed.nodes.find(n => n.id === 'output') !== undefined,
4466
+ 'output node retained');
4467
+ assert(collapsed.nodes.find(n => n.id === 'step_0') !== undefined,
4468
+ 'step_0 retained');
4113
4469
  }
4114
4470
 
4115
4471
  function testRenderer_validateStrandPayload_missingSourceSpec() {
@@ -4280,6 +4636,156 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
4280
4636
  assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
4281
4637
  }
4282
4638
 
4639
+ function testRenderer_buildRunGraphData_backboneHasNoForeachNode() {
4640
+ // Regression test for the run-mode rendering fix: the backbone
4641
+ // delivered to cytoscape must NOT contain any strand-foreach or
4642
+ // strand-collect nodes. Run mode inherits the same cosmetic
4643
+ // collapse as strand mode so the foreach/collect execution-layer
4644
+ // concepts don't leak into the view as boxed nodes.
4645
+ //
4646
+ // User scenario: [Disbind (1→n), ForEach, make_decision] where
4647
+ // target_spec equals the last cap's to_spec, so the backbone
4648
+ // collapses to 3 nodes: input_slot, step_0 (Text Page),
4649
+ // step_2 (Decision, merged target). No separate `for each` or
4650
+ // `collect` boxes.
4651
+ const strand = {
4652
+ source_spec: 'media:pdf',
4653
+ target_spec: 'media:decision',
4654
+ steps: [
4655
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4656
+ makeForEachStep('media:page'),
4657
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4658
+ ],
4659
+ };
4660
+ const payload = {
4661
+ resolved_strand: strand,
4662
+ body_outcomes: [],
4663
+ visible_success_count: 0,
4664
+ visible_failure_count: 0,
4665
+ total_body_count: 0,
4666
+ };
4667
+ const built = rendererBuildRunGraphData(payload);
4668
+
4669
+ // Backbone must contain NO foreach/collect nodes.
4670
+ const foreachNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-foreach');
4671
+ const collectNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-collect');
4672
+ assertEqual(foreachNodes.length, 0, 'run backbone must not contain strand-foreach nodes');
4673
+ assertEqual(collectNodes.length, 0, 'run backbone must not contain strand-collect nodes');
4674
+
4675
+ // The backbone fallback connector is the foreach-entry cap edge
4676
+ // that runs from the pre-foreach node to the body cap. It must
4677
+ // survive collapse so the target stays reachable even with zero
4678
+ // successful bodies.
4679
+ const backboneCapEdges = built.strandBuilt.edges.filter(e => e.edgeClass === 'strand-cap-edge');
4680
+ assert(backboneCapEdges.some(e => e.source === 'step_0' && e.target === 'step_2'),
4681
+ 'foreach-entry backbone edge step_0 → step_2 must be present for fallback connectivity');
4682
+
4683
+ // With zero outcomes, no replicas and no show-more nodes.
4684
+ assertEqual(built.replicaNodes.length, 0, 'no replica nodes when body_outcomes is empty');
4685
+ assertEqual(built.showMoreNodes.length, 0, 'no show-more nodes when no hidden outcomes');
4686
+ }
4687
+
4688
+ function testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
4689
+ // When every body fails, the strand target node was never
4690
+ // reached by any execution. The render drops BOTH the backbone
4691
+ // foreach-entry edge AND the orphaned target node so the user
4692
+ // doesn't see a stale "Decision" placeholder alongside their
4693
+ // failed replicas.
4694
+ const strand = {
4695
+ source_spec: 'media:pdf',
4696
+ target_spec: 'media:decision',
4697
+ steps: [
4698
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4699
+ makeForEachStep('media:page'),
4700
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4701
+ ],
4702
+ };
4703
+ const failedCapUrn = 'cap:in="media:page";op=decide;out="media:decision"';
4704
+ const payload = {
4705
+ resolved_strand: strand,
4706
+ body_outcomes: [
4707
+ { body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
4708
+ { body_index: 1, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
4709
+ ],
4710
+ visible_success_count: 3,
4711
+ visible_failure_count: 3,
4712
+ total_body_count: 2,
4713
+ };
4714
+ const built = rendererBuildRunGraphData(payload);
4715
+
4716
+ // The dropped placeholder: step_2 (the merged strand target
4717
+ // "Decision") is absent from the backbone because all bodies
4718
+ // failed and the replicas didn't reach it.
4719
+ const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
4720
+ assertEqual(hasStep2, false,
4721
+ 'strand target placeholder must be dropped when zero successful replicas reach it');
4722
+
4723
+ // The backbone foreach-entry edge is also gone — replicas
4724
+ // replaced it and there's no orphan target to connect.
4725
+ const foreachEntry = built.strandBuilt.edges.find(e =>
4726
+ e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
4727
+ assertEqual(foreachEntry, undefined,
4728
+ 'backbone foreach-entry edge must be dropped when replicas exist');
4729
+
4730
+ // Failures are rendered as red replica trails that don't merge.
4731
+ const failureNodes = built.replicaNodes.filter(n => n.classes === 'body-failure');
4732
+ assertEqual(failureNodes.length, 2,
4733
+ 'two failed bodies render two replica nodes (truncated at failed_cap)');
4734
+
4735
+ // The pre-foreach cap node (step_0, "Disbind") is still present
4736
+ // along with its incoming edge — only the post-body parts were
4737
+ // dropped.
4738
+ const hasStep0 = built.strandBuilt.nodes.some(n => n.id === 'step_0');
4739
+ assertEqual(hasStep0, true, 'pre-foreach cap node survives');
4740
+ const disbindEdge = built.strandBuilt.edges.find(e =>
4741
+ e.source === 'input_slot' && e.target === 'step_0');
4742
+ assert(disbindEdge !== undefined, 'pre-foreach cap edge (Disbind) survives');
4743
+ }
4744
+
4745
+ function testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful() {
4746
+ // When at least one successful replica merges into the target,
4747
+ // the backbone foreach-entry edge is dropped — the replica's
4748
+ // own chain represents the execution and the backbone would
4749
+ // otherwise duplicate the path.
4750
+ const strand = {
4751
+ source_spec: 'media:pdf',
4752
+ target_spec: 'media:decision',
4753
+ steps: [
4754
+ makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
4755
+ makeForEachStep('media:page'),
4756
+ makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
4757
+ ],
4758
+ };
4759
+ const payload = {
4760
+ resolved_strand: strand,
4761
+ body_outcomes: [
4762
+ { body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
4763
+ ],
4764
+ visible_success_count: 3,
4765
+ visible_failure_count: 3,
4766
+ total_body_count: 1,
4767
+ };
4768
+ const built = rendererBuildRunGraphData(payload);
4769
+
4770
+ // The backbone foreach-entry edge is gone.
4771
+ const foreachEntry = built.strandBuilt.edges.find(e =>
4772
+ e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
4773
+ assertEqual(foreachEntry, undefined,
4774
+ 'foreach-entry backbone edge dropped when at least one success exists');
4775
+
4776
+ // step_2 (target) stays because the replica merge edge lands on it.
4777
+ const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
4778
+ assertEqual(hasStep2, true,
4779
+ 'strand target node survives when successful replicas merge into it');
4780
+
4781
+ // Exactly one successful replica node and one merge edge.
4782
+ const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
4783
+ assertEqual(successNodes.length, 1, 'one replica node for one successful body');
4784
+ const mergeEdges = built.replicaEdges.filter(e =>
4785
+ e.data && e.data.target === 'step_2' && e.classes === 'body-success');
4786
+ assertEqual(mergeEdges.length, 1, 'one merge edge from replica to target');
4787
+ }
4788
+
4283
4789
  // ---------------- machine builder ----------------
4284
4790
 
4285
4791
  function testRenderer_validateMachinePayload_rejectsUnknownKind() {
@@ -4702,10 +5208,18 @@ async function runTests() {
4702
5208
  runTest('RENDERER: validateStrandStep_booleanIsSequence', testRenderer_validateStrandStep_requiresBooleanIsSequence);
4703
5209
  runTest('RENDERER: classifyStrandCapSteps_simple', testRenderer_classifyStrandCapSteps_capFlags);
4704
5210
  runTest('RENDERER: classifyStrandCapSteps_nested', testRenderer_classifyStrandCapSteps_nestedForks);
4705
- runTest('RENDERER: buildStrand_labelsForkBoundaries', testRenderer_buildStrandGraphData_labelsForkBoundaries);
4706
- runTest('RENDERER: buildStrand_collectFixupEdge', testRenderer_buildStrandGraphData_collectFixupEdge);
4707
- runTest('RENDERER: buildStrand_plainCapNoMarker', testRenderer_buildStrandGraphData_plainCapNoMarker);
5211
+ runTest('RENDERER: buildStrand_singleCapPlain', testRenderer_buildStrandGraphData_singleCapPlain);
4708
5212
  runTest('RENDERER: buildStrand_sequenceShowsCardinality', testRenderer_buildStrandGraphData_sequenceShowsCardinality);
5213
+ runTest('RENDERER: buildStrand_foreachCollectSpan', testRenderer_buildStrandGraphData_foreachCollectSpan);
5214
+ runTest('RENDERER: buildStrand_standaloneCollect', testRenderer_buildStrandGraphData_standaloneCollect);
5215
+ runTest('RENDERER: buildStrand_unclosedForEachBody', testRenderer_buildStrandGraphData_unclosedForEachBody);
5216
+ runTest('RENDERER: buildStrand_nestedForEachThrows', testRenderer_buildStrandGraphData_nestedForEachThrows);
5217
+ runTest('RENDERER: collapseStrand_singleCapBody', testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker);
5218
+ runTest('RENDERER: collapseStrand_unclosedForEachBody', testRenderer_collapseStrand_unclosedForEachBodyCollapses);
5219
+ runTest('RENDERER: collapseStrand_standaloneCollect', testRenderer_collapseStrand_standaloneCollectCollapses);
5220
+ runTest('RENDERER: collapseStrand_seqCapBeforeForeach', testRenderer_collapseStrand_sequenceProducingCapBeforeForeach);
5221
+ runTest('RENDERER: collapseStrand_plainCapMergesOutput', testRenderer_collapseStrand_plainCapMergesTrailingOutput);
5222
+ runTest('RENDERER: collapseStrand_plainCapDistinctTarget', testRenderer_collapseStrand_plainCapDistinctTargetNoMerge);
4709
5223
  runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
4710
5224
 
4711
5225
  console.log('\n--- cap-graph-renderer run builder ---');
@@ -4713,6 +5227,9 @@ async function runTests() {
4713
5227
  runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
4714
5228
  runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
4715
5229
  runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
5230
+ runTest('RENDERER: buildRun_backboneHasNoForeachNode', testRenderer_buildRunGraphData_backboneHasNoForeachNode);
5231
+ runTest('RENDERER: buildRun_allFailedDropsPlaceholder', testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
5232
+ runTest('RENDERER: buildRun_backboneDroppedWhenSuccessful', testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful);
4716
5233
 
4717
5234
  console.log('\n--- cap-graph-renderer machine builder ---');
4718
5235
  runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);