Runs are the core unit of your work in Strikes. They provide the context for all your data collection and represent a complete execution session. Think of runs as the “experiment” or “session” for your code.

Creating Runs

The most common way to create a run is using the context manager syntax:
import dreadnode as dn

dn.configure()

with dn.run("my-experiment"):
    # Everything in this block is part of the run
    pass
The run automatically starts when you enter the with block and ends when you exit it. All data logged (inputs, outputs, metrics, artifacts) and tasks executed within the block are associated with this run. You can also manually enter and exit runs by using __enter__ and __exit__ methods, but the context manager syntax is more pythonic.

Run Names

You can provide a name for your run to make it easier to identify:
with dn.run("training-run-v1"):
    # Named run
    pass
If you don’t provide a name, Strikes will generate one for you automatically using a combination of random words and numbers:
with dn.run():
    # Auto-named run (e.g., "clever-rabbit-492")
    pass

Run Tags

Tags help you categorize and filter runs. You can add tags when creating a run or dynamically during execution:
# Add tags when creating a run
with dn.run("my-experiment", tags=["production", "model-v2"]):

    # Add additional tags on the fly
    dn.tag("gpu-training", "checkpoint-enabled")

    # Tags can be conditional
    if use_advanced_config:
        dn.tag("advanced-config")
Tags make it easy to:
  • Find related runs in the UI and when exporting data
  • Group runs by experiment type, environment, or configuration
  • Filter and analyze results across multiple runs

Setting the Project

Runs are always associated with a project. You can specify which project a run belongs to:
# Specify a project for a single run
with dn.run("my-experiment", project="model-training"):
    pass
If you don’t specify a project, the run will use the default project configured in dn.configure() or be placed in a project named “Default”.

Run Attributes

You can add arbitrary attributes to a run for additional metadata:
with dn.run("my-experiment", environment="staging", version="1.2.3"):
    # Run with custom attributes
    pass
These attributes are stored with the run and can be used for filtering and organization when you perform data exports.

Execute Runs

You can either execute multiple runs independently from one another or in parallel with each other.

Multiple Independent Runs

You can create multiple independent runs in sequence:
# Run experiment with different learning rates
learning_rates = [0.1, 0.01, 0.001]

for lr in learning_rates:
    with dn.run(f"training-lr-{lr}"):
        dn.log_param("learning_rate", lr)
        result = train_model(lr=lr)
        dn.log_metric("accuracy", result["accuracy"])
Each run is completely separate with its own data and lifecycle.

Parallel Runs

For more efficient experimentation, you can run multiple experiments in parallel:
import asyncio

async def run_experiment(config):
    with dn.run(f"experiment-{config['id']}"):
        dn.log_params(**config)
        result = await async_train_model(**config)
        dn.log_metrics(**result)

# Define different configurations
configs = [
    {"id": 1, "learning_rate": 0.1, "batch_size": 32},
    {"id": 2, "learning_rate": 0.01, "batch_size": 64},
    {"id": 3, "learning_rate": 0.001, "batch_size": 128}
]

# Run experiments in parallel
await asyncio.gather(*[run_experiment(config) for config in configs])
This pattern is particularly useful for hyperparameter searches or evaluating multiple models.

Distributed Execution

For complex workflows that span multiple processes, hosts, or containers, you can capture and transfer run context to continue runs across distributed environments:
import dreadnode as dn

# On the main process/host
with dn.run("distributed-training") as run:
    dn.log_params(model="transformer", dataset="large-corpus")

    # Capture the run context for transfer
    context = dn.get_run_context()

    # Send context to worker processes/hosts
    # (via message queue, HTTP headers, file, etc.)
    send_to_workers(context)

# On worker processes/hosts
def worker_task(run_context):
    # Continue the same run in distributed environment
    with dn.continue_run(run_context):
        # All logging is associated with the original run
        dn.log_metric("worker_progress", 0.8)
        dn.log_output("worker_result", process_batch())
The get_run_context() function captures the necessary state for continuation, including::
  • Run ID and metadata
  • OpenTelemetry trace context for distributed tracing
  • Project name
This enables patterns like:
  • Distributed training: Continue runs across multiple GPU workers
  • Containerized workflows: Transfer runs between container steps
  • Cloud computing: Move runs between different cloud instances
  • Multi-process evaluation: Parallel evaluation across worker processes
  • Server-side processing: Continue runs in a web server context for world models or supporting system
# Example: Distributed hyperparameter search
import asyncio
from concurrent.futures import ProcessPoolExecutor

def worker_experiment(run_context, hyperparams):
    """Worker function that continues a run"""
    with dn.continue_run(run_context):
        # Train model with these hyperparams
        model = train_model(**hyperparams)
        accuracy = evaluate_model(model)

        dn.log_params(**hyperparams)
        dn.log_metric("accuracy", accuracy)
        return accuracy

async def distributed_search():
    with dn.run("hyperparameter-search") as run:
        context = dn.get_run_context()

        # Define search space
        param_combinations = [
            {"lr": 0.1, "batch_size": 32},
            {"lr": 0.01, "batch_size": 64},
            {"lr": 0.001, "batch_size": 128}
        ]

        # Run experiments in parallel across processes
        with ProcessPoolExecutor() as executor:
            futures = [
                executor.submit(worker_experiment, context, params)
                for params in param_combinations
            ]

            results = [future.result() for future in futures]

        # All worker results are automatically part of the main run
        dn.log_metric("best_accuracy", max(results))

await distributed_search()

Live Monitoring

Strikes automatically provides live monitoring for your runs. As you log metrics, parameters, and other data, updates are batched and sent to the server periodically, allowing you to watch your experiments progress in real-time through the UI.
with dn.run("training-experiment"):
    for epoch in range(100):
        # Training step
        loss = train_step()
        accuracy = evaluate_step()

        # Data appears in UI automatically - no extra work needed
        dn.log_metric("loss", loss, step=epoch)
        dn.log_metric("accuracy", accuracy, step=epoch)

        # Continue training - updates happen in the background
The default settings provide a good default balance between real-time visibility and data efficiency.

Manual Updates (Advanced)

For cases where you need immediate visibility of specific data points, you can force an update:
with dn.run("critical-experiment"):
    for checkpoint in critical_checkpoints:
        result = process_checkpoint(checkpoint)
        dn.log_metric("checkpoint_score", result.score)

        # Force immediate update be delivered.
        dn.push_update()

Error Handling

Runs automatically capture and log errors, marking the run as failed if an exception is raised. If you want to handle errors gracefully and continue logging, you can use a try-except block within the run context:
with dn.run("risky-experiment"):
    try:
        # Run code that might fail
        result = potentially_failing_function()
        dn.log_metric("success", 1.0)
    except Exception as e:
        dn.log_metric("error", 1.0)

Task Hierarchy and Relationships

Every run maintains a list of its top-level tasks (tasks that are called directly within the run context, not as children of other tasks):
with dn.run("data-processing") as run:
    # These tasks are added to the run's task list
    cleaning_result = await clean_data.run(raw_data)
    analysis_result = await analyze_data.run(cleaned_data)

# Access all top-level tasks in the run
print(f"Run has {len(run.tasks)} top-level tasks")

for task in run.tasks:
    print(f"Task: {task}")
    print(f"Run: {task.run}")
    print(f"Child tasks: {len(task.tasks)}")
You can recursively gather all tasks within a run using .all_tasks and perform analysis on them:
with dn.run("comprehensive-analysis") as run:
    await complex_workflow(data)

    all_tasks = run.all_tasks
    print(f"Run '{run.run_id}' executed {len(all_tasks)} total tasks")

    # Analyze execution times, success rates, etc.
    successful_tasks = [t for t in all_tasks if not t.failed]
    success_rate = len(successful_tasks) / len(all_tasks) if all_tasks else 0.0
    print(f"Success rate: {success_rate * 100:.1f}%")
    dn.log_metric("success_rate", success_rate)

Best Practices

  1. Use meaningful names: Give your runs descriptive names that indicate their purpose.
  2. Use parameters: Parameters are a great way to filter and compare runs later, so use them frequently.
  3. Create separate runs for separate experiments: Don’t try to jam multiple experiments into a single run—you can create multiple runs inside your code.
  4. Use projects for organization: Group related runs into projects.
  5. Create comparison runs: When testing different approaches, ensure parameters and metrics are consistent to enable meaningful comparison.
  6. Leverage task hierarchy: Organize complex workflows using hierarchical tasks within runs to maintain clear execution structure.