Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Diamonds and Iteration

What you’ll learn

  • Build diamond DAGs that diverge and reconverge

  • Implement iterative refinement loops with standard Python control flow

Prerequisites: Sources and Sequencing, Branching and Merging, Metrics and Filtering Estimated time: 15 minutes


The previous tutorials covered individual building blocks: sources, chains, fan-out, merging, filtering, and multi-input operations. This tutorial combines those primitives into composite patterns you’ll use in real pipelines.

PatternCombinesDescription
Diamond DAGFan-out + Merge + ChainDiverge into parallel branches, reconverge, then continue
Iterative refinementGeneration + Scoring + Filtering + Transformation in a loopRepeated generate → score → filter → modify cycles
from __future__ import annotations

from artisan.operations.curator import Filter, Merge
from artisan.operations.examples import (
    DataGenerator,
    DataTransformer,
    MetricCalculator,
)
from artisan.orchestration import PipelineManager
from artisan.utils import tutorial_setup
from artisan.visualization import (
    build_macro_graph,
    build_micro_graph,
    inspect_metrics,
    inspect_pipeline,
)

Graph legend: See Sources and Sequencing for box/arrow key.

Diamond DAG

A diamond is the simplest composite pattern: one source fans out to two independent branches, both branches process the same inputs differently, then a Merge step reconverges them before downstream processing continues.

              ┌─── transform (seed=100) ───┐
generate ─────┤                             ├─── merge ─── metrics
              └─── transform (seed=200) ───┘

When to use: You need to apply two independent transformations to the same inputs and then process the combined results — for example, generating candidates via two strategies and then scoring them together.

env_diamond = tutorial_setup("diamond_dag")
pipeline = PipelineManager.create(
    name="diamond_dag",
    delta_root=env_diamond.delta_root,
    staging_root=env_diamond.staging_root,
    working_root=env_diamond.working_root,
)
output = pipeline.output

# Source: generate 2 datasets
pipeline.run(operation=DataGenerator, name="generate", params={"count": 2, "seed": 42})

# Branch A: transform with one strategy
pipeline.run(
    operation=DataTransformer,
    name="transform_a",
    inputs={"dataset": output("generate", "datasets")},
    params={"seed": 100, "output_prefix": "branch_a_"},
)

# Branch B: transform with a different strategy
pipeline.run(
    operation=DataTransformer,
    name="transform_b",
    inputs={"dataset": output("generate", "datasets")},
    params={"seed": 200, "output_prefix": "branch_b_"},
)

# Reconverge: merge both branches into a single stream
pipeline.run(
    operation=Merge,
    name="merge",
    inputs={
        "branch_a": output("transform_a", "dataset"),
        "branch_b": output("transform_b", "dataset"),
    },
)

# Continue: score the combined results
pipeline.run(
    operation=MetricCalculator,
    name="metrics",
    inputs={"dataset": output("merge", "merged")},
)

result_diamond = pipeline.finalize()
build_macro_graph(env_diamond.delta_root)

The macro graph shows the diamond shape: data_generator feeds both data_transformer steps (fan-out), which converge on merge, and then metric_calculator processes the unified stream. The Merge step produces no new artifacts — it unions the two branches using passthrough semantics, so metric_calculator receives all 4 datasets (2 from each branch).

inspect_pipeline(env_diamond.delta_root)

The pipeline overview confirms the flow: step 0 produced 2 datasets, steps 1 and 2 each transformed those 2 into 2 more, Merge passed all 4 through, and metric_calculator scored each one.

build_micro_graph(env_diamond.delta_root)

Iterative refinement

Many real workflows follow a loop: generate candidates, score them, keep the best, modify survivors, and repeat. Artisan handles this with a standard Python for loop around pipeline.run() calls — no special API needed.

generate ──→ score ──→ filter ──→ transform ──→ score ──→ filter ──→ ...

Each iteration adds new steps to the same pipeline. Provenance tracks lineage across all rounds automatically.

When to use: Design optimization, evolutionary search, iterative screening — any workflow where you repeatedly score, select, and regenerate.

Building one round

Before looping, let’s build a single round to understand each piece. The four steps are:

  1. Generate — create initial candidate datasets

  2. Score — compute metrics on each candidate

  3. Filter — keep candidates that meet a threshold

  4. Transform — modify survivors for the next round

env_single = tutorial_setup("cycling_single_round")
pipeline = PipelineManager.create(
    name="cycling_single_round",
    delta_root=env_single.delta_root,
    staging_root=env_single.staging_root,
    working_root=env_single.working_root,
)
output = pipeline.output

# 1. Generate 6 initial candidates
pipeline.run(operation=DataGenerator, name="generate", params={"count": 6, "seed": 42})
datasets = output("generate", "datasets")

# 2. Score each candidate
pipeline.run(operation=MetricCalculator, name="score", inputs={"dataset": datasets})

# 3. Filter: keep candidates with max value >= 0
pipeline.run(
    operation=Filter,
    name="filter",
    inputs={"passthrough": datasets},
    params={
        "criteria": [
            {"metric": "distribution.max", "operator": "ge", "value": 0},
        ]
    },
)

# 4. Transform survivors
pipeline.run(
    operation=DataTransformer,
    name="transform",
    inputs={"dataset": output("filter", "passthrough")},
    params={"seed": 100},
)

result_single = pipeline.finalize()
inspect_metrics(env_single.delta_root)

The metrics table shows each candidate’s scores. The Filter step used distribution.max >= 0 to decide which candidates survive. Filter auto-discovers the metrics from the score step via forward provenance walk from the passthrough datasets — no explicit metric input wiring needed. Candidates that pass continue to the Transform step; those that fail are dropped.

build_macro_graph(env_single.delta_root)
build_micro_graph(env_single.delta_root)

The graphs show the single-round flow: generate → score → filter → transform. Notice that Filter has no direct edge from the score step — it discovers the metrics automatically via forward provenance walk from the passthrough datasets.

Looping over multiple rounds

Now wrap the score → filter → transform sequence in a for loop. Each round’s output feeds into the next round’s input. The key insight: pipeline.output() returns OutputReference objects that can be passed directly to the next pipeline.run() call. A Python variable reassignment (datasets = ...) is all you need to chain rounds together.

env_cycle = tutorial_setup("cycling_multi_round")

N_ROUNDS = 3
pipeline = PipelineManager.create(
    name="cycling_multi_round",
    delta_root=env_cycle.delta_root,
    staging_root=env_cycle.staging_root,
    working_root=env_cycle.working_root,
)
output = pipeline.output

# Initial generation
pipeline.run(operation=DataGenerator, name="generate", params={"count": 6, "seed": 42})
datasets = output("generate", "datasets")

# Iterative refinement: score → filter → transform, repeated N rounds
for round_num in range(N_ROUNDS):
    # Score current candidates
    scored_name = f"score_r{round_num}"
    pipeline.run(
        operation=MetricCalculator, name=scored_name, inputs={"dataset": datasets}
    )

    # Filter: keep candidates above threshold
    filter_name = f"filter_r{round_num}"
    pipeline.run(
        operation=Filter,
        name=filter_name,
        inputs={"passthrough": datasets},
        params={
            "criteria": [
                {"metric": "distribution.max", "operator": "ge", "value": 0},
            ]
        },
    )

    # Transform survivors for the next round
    transform_name = f"transform_r{round_num}"
    pipeline.run(
        operation=DataTransformer,
        name=transform_name,
        inputs={"dataset": output(filter_name, "passthrough")},
        params={"seed": (round_num + 1) * 100},
    )

    # Feed modified datasets into the next round
    datasets = output(transform_name, "dataset")

result_cycle = pipeline.finalize()

Each round adds 3 steps (score, filter, transform), so the total grows linearly with N_ROUNDS. Each round’s Transform output became the next round’s Score input through the datasets variable reassignment.

No special framework API was needed — the loop is plain Python. The framework handles provenance tracking across rounds automatically.

inspect_pipeline(env_cycle.delta_root)

The pipeline overview shows all the steps. Each group of three (metric_calculator → filter → data_transformer) is one round. Watch the artifact counts: if the filter drops candidates in early rounds, later rounds process fewer artifacts.

inspect_metrics(env_cycle.delta_root)

The metrics table shows scores from every round. Metrics from round 0 are computed on the initial candidates; round 1 metrics come from the transformed survivors, and so on. Comparing across rounds reveals whether the iterative process is improving candidates.

build_macro_graph(env_cycle.delta_root)

The macro graph shows the repeating pattern across rounds. Each round’s score → filter → transform subgraph is connected to the previous round’s output, forming a chain of refinement cycles. Provenance arrows trace lineage from the initial candidates through every transformation.

build_micro_graph(env_cycle.delta_root)

Use the stepper to walk through rounds one step at a time. This is the best way to verify that lineage is tracked correctly across round boundaries — each round’s Transform outputs should trace back through the Filter to the previous round’s Transform outputs.

Summary

This tutorial covered two composite patterns that build on the fundamentals from previous tutorials:

  • Diamond DAG — fan-out into parallel branches, merge, and continue. Combines fan-out and merge with downstream processing.

  • Iterative refinement — a Python for loop around pipeline.run() calls implements score → filter → transform cycles. No special API needed; provenance tracks lineage across all rounds automatically.

Key takeaway: Artisan’s composability comes from Python, not from framework abstractions. OutputReference objects are the wiring mechanism, and standard control flow (loops, conditionals) is how you build complex topologies.

Next: Composable Operations — defining reusable operation compositions with CompositeDefinition

Deeper understanding: