Skip to content

Workflow Contexts

Workflow contexts are the APIs that coordinate multi-step pipelines.

When to use workflow contexts

  • Use Tracker.scenario(...) or consist.scenario(...) to group related steps under one scenario header run.
  • Use ScenarioContext.run(...) for cache-aware function-shaped steps.
  • Use ScenarioContext.map_runs(...) for independent parameter sweeps where each row should become one child run under the scenario.
  • Use ScenarioContext.trace(...) when the inline block must execute every time (including cache hits).
  • Use RunContext inside inject_context=True runs for managed output paths and run-aware logging helpers.

Relationship to consist.run and Tracker.run

API Execution shape Cache-hit behavior
consist.run / Tracker.run Single function call Skips function execution and reuses cached outputs
ScenarioContext.run Function step inside scenario Same cache behavior as Tracker.run
ScenarioContext.map_runs Many independent function runs inside scenario Executes one child run per row and records a batch result
ScenarioContext.trace / Tracker.trace Inline with block Block executes each time; cache state is still recorded

Scenario step identity kwargs

Tracker.scenario(...) creates the header context. Identity kwargs are passed on step surfaces (ScenarioContext.run(...) / ScenarioContext.trace(...)):

with tracker.scenario("baseline") as sc:
    step = sc.run(
        fn=prepare,
        name="prepare",
        adapter=activitysim_adapter,
        identity_inputs=[("asim_config", asim_config_dir)],
        outputs=["prepared"],
    )

    with sc.trace(
        "analyze",
        adapter=activitysim_adapter,
        identity_inputs=[("asim_config", asim_config_dir)],
    ):
        analyze_inline(step.outputs["prepared"])

config_plan and hash_inputs are not accepted on scenario step run/trace surfaces. Use adapter and identity_inputs.

Tracker.scenario(...) and consist.scenario(...) also accept step_tags=[...] and step_facet={...} to apply default tags/facets to every child step in the scenario. These defaults apply to run(...) and trace(...) children only, not to the scenario header itself, and per-step values take precedence on conflicts.

BindingResult execution envelopes

ScenarioContext.run(...) also accepts binding=BindingResult(...) for orchestrator-facing code that has already resolved a step's explicit inputs, required coupler keys, and optional coupler keys. Keep using primitive inputs/input_keys/optional_input_keys kwargs for direct workflow code; the binding envelope is the preferred path when the binding decision already lives outside Consist.

binding is execution-only and mutually exclusive with the primitive input kwargs. It does not change ScenarioContext.trace(...).

from pathlib import Path
import consist
from consist import BindingResult, ExecutionOptions, Tracker

tracker = Tracker(run_dir="./runs", db_path="./provenance.duckdb")


def prepare(raw: Path) -> dict[str, Path]:
    out = consist.output_path("prepared", ext="parquet")
    out.write_text("prepared\n")
    return {"prepared": out}


def analyze(prepared: Path) -> dict[str, Path]:
    out = consist.output_path("analysis", ext="parquet")
    out.write_text(prepared.read_text())
    return {"analysis": out}


with tracker.scenario("baseline") as sc:
    prep = sc.run(
        fn=prepare,
        inputs={"raw": Path("raw.csv")},
        execution_options=ExecutionOptions(input_binding="paths"),
        outputs=["prepared"],
    )

    sc.run(
        fn=analyze,
        binding=BindingResult(
            inputs={"prepared": consist.ref(prep, key="prepared")},
            input_keys=["prepared"],
        ),
        execution_options=ExecutionOptions(input_binding="loaded"),
        outputs=["analysis"],
    )

For direct step-to-step workflow code, consist.ref(...) and consist.refs(...) remain the recommended default. Reach for BindingResult when a planner or external orchestrator has already compiled the binding decision.

Immutable resolved bindings (advanced)

ResolvedBinding is a stricter, opt-in contract for an external planner that must replay one already-admitted artifact choice exactly. It is not the normal Coupler workflow: use inputs=, consist.ref(...), and BindingResult for ordinary scenario composition.

A ResolvedBinding contains typed artifact identities, tracked-artifact locators, the callable's step-contract identity, and optional admission evidence. ScenarioContext.run(...) accepts it only with ExecutionOptions(input_binding="paths"). Before invoking the callable, Consist verifies the artifact identity and stages a fresh, run-owned copy at each declared destination; it refuses an unsafe destination or a mismatched callable. Every strict invocation is written to the run_binding_invocation ledger. Cache hits keep their producer run immutable and record that producer as cache_source_run_id on the new invocation record.

Build these bindings in orchestration code with ResolvedBindingBuilder; do not construct them from unverified paths or use them to replace the normal Coupler-based workflow.

When a planner must construct a binding before run(...), ask the Scenario for the final identity rather than reproducing decorator or scenario naming rules:

execution = ExecutionOptions(input_binding="paths", runtime_kwargs=runtime)
identity = scenario.resolve_step_identity(
    run_beam,
    year=year,
    iteration=iteration,
    phase="warmstart",
    execution_options=execution,
)

binding = (
    consist.ResolvedBindingBuilder(
        step_name=identity.name,
        step_contract_identity=identity.step_contract_identity,
    )
    .bind_tracked_artifact(
        parameter="linkstats_warmstart",
        artifact=admitted_linkstats,
        destination=Path("inputs/linkstats.parquet"),
        source="external_admitted",
    )
    .freeze()
)
scenario.run(
    run_beam,
    binding=binding,
    step_identity=identity,
    execution_options=execution,
)

StepIdentity is an opaque, Scenario-owned preflight result. run(...) reuses it without re-evaluating dynamic name/model metadata and rejects a different callable, Scenario, dynamic context, or runtime kwargs. Do not pass name= or model= again when supplying step_identity=.

For an artifact already admitted through Consist, use the tracked-artifact bridge rather than reconstructing an identity or locator in downstream code:

binding = (
    consist.ResolvedBindingBuilder(
        step_name="beam",
        step_contract_identity=step_contract_identity(run_beam, "beam"),
    )
    .bind_tracked_artifact(
        parameter="linkstats_warmstart",
        artifact=admitted_linkstats,
        destination=Path("inputs/linkstats.parquet"),
        source="external_admitted",
    )
    .with_diagnostics({"selection": {"reason": "approved warm start"}})
    .freeze()
)

The strict contract has two serializations. identity_json() contains the step contract plus each parameter's typed identity and destination; its digest partitions cache reuse. evidence_json() adds tracked locators, selection details, metadata, admission evidence, and diagnostics. The latter is written to run_binding_invocation, so explaining why a fallback was selected never changes cache reuse.

For Python ResolvedBinding runs only, immutable typed bound inputs use content-addressed cache identity. Ordinary BindingResult, primitive inputs=, Coupler values, and legacy directories keep their normal provenance-Merkle identity. This is not a container image-cache contract: native executor="container" strict bindings are rejected.

Inspect run.identity_summary["input_identity"] to see the strict/ordinary input split. Inspect tracker.db.get_binding_invocations(requested_run_id=...) for the selected artifact, producer, and immutable audit evidence on each hit or miss.

Snapshot and callable-path invariant

For input_binding="paths", a strict input parameter receives the fresh run-owned snapshot under .resolved-bindings/<run-id>/<destination>, not the original admitted path. A model that reads a workspace path or a container-mapped path must be configured so that its final visible path resolves to those snapshot bytes. A separately staged workspace copy is not equivalent merely because it has the same intended filename; it must resolve to the strict snapshot (or be independently verified against its typed identity).

V1 scope limitation

Every V1 strict input must correspond to a named callable parameter. Consist does not yet model a strict staged_artifact that participates in identity and staging while being accessed only through a workspace destination or RunContext. Do not hide dynamic artifacts behind fake parameters or omit them from strict identity; keep those restart closures on the ordinary workflow path until that explicit artifact mode exists.

Parallel sweep runs

ScenarioContext.map_runs(...) is the scenario-level fan-out helper for embarrassingly parallel sweeps. It builds one BatchRunSpec per input row, executes each spec in a worker process, and returns a BatchResult summary.

Use it when:

  • Every row can run independently.
  • The worker function is importable as "module:function".
  • The parent scenario should group all child runs for later queries.
  • You want retry metadata for failed rows.
rows = [
    {"case_id": 0, "seed": 10, "elasticity": -0.2},
    {"case_id": 1, "seed": 11, "elasticity": -0.4},
]

with tracker.scenario("elasticity_sweep") as sc:
    batch = sc.map_runs(
        rows=rows,
        fn="project.steps:run_elasticity_case",
        name_template="case_{case_id}",
        model="demand_model",
        config_from=lambda row: {
            "seed": row["seed"],
            "elasticity": row["elasticity"],
        },
        facet_from=lambda row: {
            "case_id": row["case_id"],
            "elasticity": row["elasticity"],
        },
        expected_outputs=["summary"],
    )

The returned BatchResult separates correlation metadata from persisted run metadata:

  • BatchResult.results contains RunSpecResult objects for observed outcomes.
  • RunSpecResult.run_id is the requested child run ID for the spec.
  • RunSpecResult.persisted_run_id is set only when a run record exists.
  • BatchResult.child_run_ids includes only persisted child run IDs.
  • BatchResult.failed_specs contains BatchRunSpec objects that can be retried.

The current backend is backend="processes". It requires an importable callable reference and does not support direct callables, lambdas, or notebook-local functions. cancel_pending_on_failure=True makes a best-effort attempt to cancel work that has not started after the first observed failure; running workers may still complete.

Minimal runnable scenario example

from pathlib import Path
import consist
from consist import Tracker

tracker = Tracker(run_dir="./runs", db_path="./provenance.duckdb")

def prepare() -> Path:
    out = consist.output_path("prepared", ext="txt")
    out.write_text("prepared\n")
    return out

def analyze(prepared_artifact) -> Path:
    out = consist.output_path("analysis", ext="txt")
    out.write_text(prepared_artifact.path.read_text())
    return out

with tracker.scenario("baseline") as sc:
    prep = sc.run(fn=prepare, name="prepare", outputs=["prepared"])
    analysis = sc.run(
        fn=analyze,
        name="analyze",
        inputs={"prepared_artifact": consist.ref(prep, key="prepared")},
        outputs=["analysis"],
    )

Scenario Context

A context manager for grouping multiple steps into a single "scenario".

A scenario creates a parent run (the "header") that aggregates the results, metadata, and lineage of all steps executed within its block. It provides a coupler to pass artifacts between steps, making it ideal for multi-stage simulation workflows.

Attributes:

Name Type Description
coupler Coupler

Scenario-local artifact registry for passing outputs between steps. Supports runtime-declared output validation.

Examples:

with tracker.scenario("base_case") as sc:
    # Step 1: Pre-process
    sc.run(preprocess_fn, inputs={"raw": "data.csv"}, outputs=["clean"])
    # Step 2: Model (reads "clean" from the coupler automatically)
    sc.run(model_fn, input_keys=["clean"], outputs=["results"])

run_id property

Run ID of the scenario header.

Returns:

Type Description
str

The run ID for the scenario header (or the scenario name if the header has not been created yet).

config property

Read-only view of the scenario configuration.

Returns:

Type Description
MappingProxyType

Immutable mapping of configuration values for the scenario. Updates are applied by changing inputs to the scenario, not by mutating this mapping.

inputs property

Read-only view of registered exogenous inputs.

Returns:

Type Description
MappingProxyType

Immutable mapping of input keys to artifacts added via add_input. This reflects only scenario-level inputs, not step inputs.

add_input(path, key, **kwargs)

Log an external input artifact to the scenario header run.

Parameters:

Name Type Description Default
path ArtifactRef

Path (or prebuilt Artifact) representing the input.

required
key str

Semantic key for the artifact.

required
**kwargs Any

Additional metadata forwarded to Tracker.log_artifact.

{}

Returns:

Type Description
Artifact

Logged artifact associated with the scenario.

Raises:

Type Description
RuntimeError

If a step has already started or the scenario context is inactive.

declare_outputs(*names, required=False, warn_undefined=False, description=None)

Declare outputs that should be present in the scenario coupler.

Parameters:

Name Type Description Default
*names str

Output keys to declare.

()
required bool | Mapping[str, bool]

Whether declared outputs are required. A mapping allows per-key overrides.

False
warn_undefined bool

If True, warn when outputs are logged that were not declared.

False
description Optional[Mapping[str, str]]

Human-readable descriptions for declared outputs.

None

require_outputs(*names, required=True, warn_undefined=False, description=None)

Declare required outputs that must be present at scenario exit.

This is a convenience wrapper around declare_outputs that defaults required=True.

Parameters:

Name Type Description Default
*names str

Output keys to require.

()
required bool | Mapping[str, bool]

Whether required outputs are enforced. A mapping allows per-key overrides.

True
warn_undefined bool

If True, warn when outputs are logged that were not declared.

False
description Optional[Mapping[str, str]]

Human-readable descriptions for required outputs.

None

collect_by_keys(artifacts, *keys, prefix='')

Collect explicit artifacts into the scenario coupler by key.

Parameters:

Name Type Description Default
artifacts Mapping[str, Artifact]

Source artifacts mapping (usually outputs from a step).

required
*keys str

Keys to collect from the mapping.

()
prefix str

Optional prefix to apply to collected keys in the coupler.

""

Returns:

Type Description
Dict[str, Artifact]

The collected artifacts keyed by their (possibly prefixed) names.

run(fn=None, name=None, *, run_id=None, model=None, step_identity=None, description=None, config=None, adapter=None, config_plan_ingest=True, config_plan_profile_schema=False, inputs=None, input_keys=None, optional_input_keys=None, binding=None, depends_on=None, tags=None, facet=None, facet_from=None, facet_schema_version=None, facet_index=None, identity_inputs=None, year=None, iteration=None, phase=None, stage=None, parent_run_id=None, outputs=None, output_paths=None, output_sets=None, profile_file_schema=None, capture_dir=None, capture_pattern='*', cache_options=None, output_policy=None, execution_options=None)

Execute a cached scenario step and update the Coupler with outputs.

This method wraps Tracker.run while ensuring the scenario header is updated with step metadata and artifacts. Decorator metadata is resolved once by the scenario before it delegates the fully resolved invocation to Tracker.run. In particular, decorated input_keys and optional_input_keys are compatibility metadata and do not cause a deprecation warning solely because of this internal handoff. Use execution_options.runtime_kwargs for runtime-only inputs and consist.require_runtime_kwargs to validate required keys. For direct workflow code, prefer primitive inputs= kwargs and, when needed, the direct input_keys= / optional_input_keys= compatibility surfaces. For complex or externally orchestrated workflows that already resolved the binding plan, pass binding=BindingResult(...) instead; binding is an execution envelope and is mutually exclusive with the primitive input kwargs. Pass policy controls via cache_options, output_policy, and execution_options.

adapter and identity_inputs are the public identity-related kwargs. profile_file_schema is the scenario-step equivalent of Tracker.run(..., profile_file_schema=...): it profiles named tabular inputs and declared tabular outputs logged during this step.

Examples:

Direct workflow code:

sc.run(
    fn=step,
    inputs={"raw": raw_path},
    execution_options=ExecutionOptions(input_binding="paths"),
)

sc.run(
    fn=step,
    inputs={"raw": consist.ref(previous_result, key="raw")},
    execution_options=ExecutionOptions(input_binding="loaded"),
)

Orchestrator-facing execution envelope:

sc.run(
    fn=step,
    binding=BindingResult(
        inputs={"raw": raw_path},
        input_keys=["data"],
        optional_input_keys=["maybe"],
    ),
    execution_options=ExecutionOptions(input_binding="paths"),
)

binding cannot be combined with primitive inputs, input_keys, or optional_input_keys.

map_runs(rows, fn, name_template=None, model=None, config_from=None, facet_from=None, max_workers=None, backend='processes', cancel_pending_on_failure=False, expected_outputs=None)

Fan out parameter sweep runs in parallel under the parent scenario.

Parameters:

Name Type Description Default
rows Iterable[Dict[str, Any]]

Rows of inputs/parameter combinations to sweep.

required
fn str

Importable module-path string reference (e.g. "my_module:run_case"). Direct callables are not supported for the process backend.

required
name_template Optional[str]

Optional string template to format run names (e.g. "case-{case_id}").

None
model Optional[str]

The model name for categorizing child runs. Defaults to function name.

None
config_from Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]

Optional function to transform a row into a run configuration dict.

None
facet_from Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]

Optional function to extract facets from a row.

None
max_workers Optional[int]

The maximum number of parallel workers. Defaults to executor default.

None
backend str

The backend executor: "processes" (ProcessPoolExecutor) or "threads" (ThreadPoolExecutor).

"processes"
cancel_pending_on_failure bool

If True, make a best-effort attempt to cancel futures that have not started after the first observed failure. Already-running workers may still complete.

False

Returns:

Type Description
BatchResult

The aggregated results of the parallel run execution.

trace(name, *, run_id=None, model=None, description=None, config=None, adapter=None, config_plan_ingest=True, config_plan_profile_schema=False, inputs=None, input_keys=None, optional_input_keys=None, depends_on=None, tags=None, facet=None, facet_from=None, facet_schema_version=None, facet_index=None, identity_inputs=None, year=None, iteration=None, parent_run_id=None, outputs=None, output_paths=None, capture_dir=None, capture_pattern='*', cache_mode='reuse', cache_hydration=None, cache_version=None, cache_epoch=None, validate_cached_outputs='lazy', code_identity=None, code_identity_extra_deps=None, output_mismatch='warn', output_missing='warn')

Manual tracing context manager for scenario steps.

This wraps Tracker.trace to log a step while allowing inline code blocks. Use ScenarioContext.run when you want function execution to be skipped on cache hits.

adapter and identity_inputs are the public identity-related kwargs.

Run Context

Provide run-aware helpers to a Python callable.

Consist injects this object when inject_context is enabled. It exposes output locations, artifact logging helpers, and canonicalization facts without requiring application code to access a global tracker.

Parameters:

Name Type Description Default
tracker Tracker

Active tracker for the executing run.

required
canonicalization CanonicalizationSnapshot

Immutable observations from the applied config plan. These are canonicalization-time facts, not final staged or container paths.

None
Notes

Cache hits and native container executions do not invoke the Python callable, so they do not receive this context.

Examples:

>>> def my_step(ctx: RunContext):
...     output_path = ctx.run_dir / "results.csv"
...     ctx.log_artifact(output_path, "results")

run_dir property

Run-specific output directory for the active step.

Returns:

Type Description
Path

The directory where this step should write outputs by default. This value is derived from the active run and respects any per-run artifact directory overrides.

inputs property

Mapping of input artifact keys to artifacts for the active step.

Returns:

Type Description
Dict[str, Artifact]

Dictionary of input artifacts keyed by their semantic keys. Raises a RuntimeError if accessed outside an active run.

output_dir(namespace=None)

Resolve the managed output directory for the active run.

Parameters:

Name Type Description Default
namespace Optional[str]

Optional relative subdirectory under the managed run output directory.

None

Returns:

Type Description
Path

Absolute directory path for managed outputs. The directory is created if it does not exist.

output_path(key, ext='parquet')

Resolve a deterministic managed output path for the active run.

Parameters:

Name Type Description Default
key str

Artifact key used as the output filename stem. Relative path segments are allowed to organize outputs into subdirectories.

required
ext str

File extension to append. Leading dots are ignored and the extension is normalized to lowercase.

"parquet"

Returns:

Type Description
Path

Absolute managed output path. Parent directories are created if needed.

load(key_or_artifact)

Load data from an input artifact by key or from an Artifact instance.

Parameters:

Name Type Description Default
key_or_artifact Union[str, Artifact]

Input artifact key from inputs or an Artifact object.

required

Returns:

Type Description
Any

Loaded data (driver-dependent).

log_artifact(*args, **kwargs)

Log an artifact within the active run.

This is a thin wrapper around Tracker.log_artifact.

Parameters:

Name Type Description Default
*args Any

Positional arguments forwarded to Tracker.log_artifact.

()
**kwargs Any

Keyword arguments forwarded to Tracker.log_artifact.

{}

Returns:

Type Description
Artifact

The logged artifact.

log_artifacts(*args, **kwargs)

Log multiple artifacts within the active run.

This is a thin wrapper around Tracker.log_artifacts.

Parameters:

Name Type Description Default
*args Any

Positional arguments forwarded to Tracker.log_artifacts.

()
**kwargs Any

Keyword arguments forwarded to Tracker.log_artifacts.

{}

Returns:

Type Description
Dict[str, Artifact]

Mapping of artifact keys to logged artifacts.

log_input(*args, **kwargs)

Log an input artifact within the active run.

This is a thin wrapper around Tracker.log_input.

Parameters:

Name Type Description Default
*args Any

Positional arguments forwarded to Tracker.log_input.

()
**kwargs Any

Keyword arguments forwarded to Tracker.log_input.

{}

Returns:

Type Description
Artifact

The logged input artifact.

log_output(*args, **kwargs)

Log an output artifact within the active run.

This is a thin wrapper around Tracker.log_output.

Parameters:

Name Type Description Default
*args Any

Positional arguments forwarded to Tracker.log_output.

()
**kwargs Any

Keyword arguments forwarded to Tracker.log_output.

{}

Returns:

Type Description
Artifact

The logged output artifact.

log_meta(**kwargs)

Update metadata for the active run.

This is a thin wrapper around Tracker.log_meta.

Parameters:

Name Type Description Default
**kwargs Any

Metadata key/value pairs to merge into the run record.

{}

capture_outputs(directory, pattern='*')

Capture files written under directory and log them as outputs on exit.

Parameters:

Name Type Description Default
directory Path

Directory to monitor for new or modified files.

required
pattern str

Glob pattern for files to capture.

"*"

Yields:

Type Description
OutputCapture

Container listing artifacts that were logged during the context.

Coupler

Bases: CouplerMapMixin[Artifact, DeclaredOutput]

Scenario-local helper to thread named artifacts between steps.

Coupler is intentionally small: - It stores the "latest Artifact for a semantic key" in-memory.

It does not log artifacts, infer inputs/outputs, or mutate Artifacts as a side effect of reads. Keep provenance operations on the Tracker.

set(key, artifact)

Store an artifact under a validated key.

set_from_artifact(key, value)

Set an artifact, accepting both Artifact objects and artifact-like values.

This method is useful when integrating with optional dependencies (like noop mode) where you may receive either: - A real Artifact (when tracking is enabled) - An artifact-like object with .path and .container_uri properties (noop mode) - A Path or string (fallback)

All three forms are stored in the coupler and can be retrieved with get() or require().

Parameters:

Name Type Description Default
key str

The coupler key (e.g., "persons", "skims"). Must follow artifact-key rules.

required
value Artifact or artifact - like or Path or str

The value to store. Can be a real Artifact, artifact-like object with .path/.container_uri properties, a Path, or a string path.

required

Returns:

Type Description
Any

The value that was stored.

Examples:

Using with optional Consist dependency:

# Works whether log_output returns Artifact or NoopArtifact
artifact = tracker.log_output(path, key="persons")
coupler.set_from_artifact("persons", artifact)

Mixed real and fallback artifacts:

artifact = log_output(path) or path  # Fallback to path string
coupler.set_from_artifact("key", artifact)  # Handles both

update(artifacts=None, /, **kwargs)

Bulk-store artifacts from a mapping and/or keyword arguments.

view(namespace)

Return a namespace-scoped coupler view.

The returned view writes keys as <namespace>/<key> into this coupler while allowing reads and writes using namespace-local key names.

get(key)

Return artifact for key or None if unset.

require(key)

Return artifact for key or raise a helpful KeyError.

keys()

items()

values()

path(key, *, required=True)

Resolve an artifact's URI to an absolute host path.

This does not mutate the Artifact; it only returns a resolved Path.

declare_outputs(*names, required=False, warn_undefined=False, description=None)

Declare expected output keys and optional metadata.

missing_declared_outputs()

Return required declared outputs that are still unset.

require_outputs(*names, required=True, warn_undefined=False, description=None)

Convenience wrapper for declaring outputs as required.

collect_by_keys(artifacts, *keys, prefix='')

Copy selected artifact keys into this coupler with an optional prefix.

describe_outputs()

Return a copy of declared-output descriptions.