Part V: Discovery Through Simulation and Optimization
Chapter 47: Experiment Registries and Scientific Provenance

47.1 Registry Design and Artifact Versioning

"I have 347 model checkpoints, 23 dataset versions, and 14 config files. I know exactly which combinations produced my best result. I wrote it on a sticky note. The sticky note is gone."

A Gradient Descent That Lost Its Provenance

Prerequisites

This section assumes familiarity with Git version control and basic command-line workflows. Readers who completed Chapter 22: MLOps, LLMOps, and AgentOps will recognize the model lifecycle concepts that registries formalize. Understanding of cryptographic hash functions (SHA-256) at the level covered in Chapter 6: Discovery System Architecture is helpful but not required; we introduce the necessary concepts here.

The Big Picture

An experiment registry is the laboratory notebook of computational science. Where a bench scientist records reagent lot numbers, instrument settings, and procedural steps, a computational scientist must record code versions, data snapshots, hyperparameter values, random seeds, and environment specifications. This section designs a registry from first principles: we define what artifacts are, how to identify them unambiguously through content-addressable hashing, and how to version each artifact class (code, data, models, configurations) using tools that scale from a single researcher's laptop to a multi-team research organization.

1. What Is an Experiment Registry?

You trained a model last Tuesday that outperformed everything else by 12%, but when your collaborator asks which dataset version, learning rate, and preprocessing script produced that result, you realize the answer is scattered across three terminal sessions, a Slack message, and a Jupyter notebook you may have overwritten. An experiment registry exists to make that situation impossible: it is a structured store that records three categories of information about every computational experiment.

More precisely, an experiment registry is a metadata database layered on top of artifact storage. It does not merely archive files; it maintains a queryable graph of which code, data, configuration, and environment produced every result. Without such a registry, reproducing or comparing past experiments forces you to reconstruct the exact input combination from scattered files, commit logs, and personal notes. That reconstruction fails as soon as a single link is missing. The registry assigns each artifact a content-derived identifier (typically a cryptographic hash), then records the directed relationships between inputs, execution events, and outputs in a structured schema. Use a registry rather than ad-hoc folder naming or spreadsheets whenever your project involves more than a handful of experiment runs, whenever multiple people collaborate on the same pipeline, or whenever you need to demonstrate reproducibility to reviewers or regulators.

  1. Artifacts: the tangible objects produced and consumed by experiments (datasets, code, model weights, configuration files, evaluation reports).
  2. Runs: the execution events that transform input artifacts into output artifacts (a training run, a preprocessing step, an evaluation pass).
  3. Relationships: the directed edges connecting runs to their input and output artifacts, forming a directed acyclic graph (DAG), where DAG means a graph whose edges all flow in one direction with no cycles, that captures the complete lineage of every result.

The registry answers one question: given any artifact, how was it produced? The sub-questions grow more demanding with rigor. At the simplest level, you want to know which code and data produced a model. At the most rigorous, you want to regenerate it bit-for-bit from the registry's metadata alone.

Key Insight: Reproducibility Is a Spectrum

Reproducibility is not binary; it exists on a spectrum from repeatability (same team, same setup, same result) through reproducibility (different team, same method, consistent result) to replicability (different team, different method, same conclusion). A registry that captures code and hyperparameters enables repeatability. Adding exact data versions and environment specifications enables reproducibility. Adding provenance metadata that documents why choices were made enables others to attempt replication with alternative approaches. The registry design in this section targets full reproducibility, with hooks for the richer metadata that supports replication.

2. Content-Addressable Storage

When an experiment fails to reproduce, the root cause is frequently not the algorithm itself but rather a missing or misidentified artifact. A team reruns training with what they believe is the same dataset, only to discover weeks later that a silent preprocessing update changed hundreds of rows, invalidating every comparison. Content-addressable storage eliminates this entire class of failure by binding each artifact's identity to its actual contents.

The foundation of artifact versioning is content-addressable storage (CAS): identifying every artifact by the cryptographic hash of its contents rather than by a human-assigned name or path. Git uses this principle for source code; Data Version Control (DVC) extends it to large binary files. The key property is that the identifier is deterministic and collision-resistant (meaning it is computationally infeasible to find two different inputs that produce the same hash): if two artifacts have the same hash, they have the same contents (with negligible probability of collision), and any change to the contents produces a completely different hash.

Mental Model

Think of content-addressable storage like a library that shelves books not by title or author, but by a unique fingerprint derived from scanning every page. If two copies are identical, page for page, they share the same fingerprint and occupy the same shelf slot; the library never stores a duplicate. If someone changes even a single comma on page 47, the fingerprint changes completely and the modified copy gets its own shelf. This is why content-addressable storage detects tampering automatically: retrieving a book means re-scanning its pages and confirming the fingerprint still matches. If it does not, you know something changed, even if the title on the spine looks the same.

For an artifact with byte content \(b\), the content address is:

$$\text{addr}(b) = \text{SHA-256}(b)$$

This gives us a 256-bit identifier (typically displayed as a 64-character hexadecimal string) that serves as both a unique name and an integrity check. (With 2256 possible addresses, roughly 1077, the chance of two different artifacts colliding on the same hash is smaller than the odds of randomly picking the same atom twice from the entire observable universe.) When we store an artifact, we compute its hash; when we retrieve it, we recompute the hash and verify it matches. Any corruption, truncation, or tampering is detected automatically. In short: name every artifact by its content, and the name itself becomes the proof that nothing has changed.

"""Content-addressable artifact storage with hash verification."""
import hashlib
import json
import shutil
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional


@dataclass
class ArtifactMetadata:
    """Metadata for a registered artifact."""
    artifact_type: str          # "dataset", "model", "config", "code", "report"
    sha256: str                 # Content hash
    size_bytes: int             # File size
    created_at: str             # ISO 8601 timestamp
    source_run_id: Optional[str] = None  # Run that produced this artifact
    tags: dict = field(default_factory=dict)
    parent_hashes: list = field(default_factory=list)  # Input artifact hashes


def compute_hash(file_path: Path, chunk_size: int = 8192) -> str:
    """Compute SHA-256 hash of a file, streaming to handle large files."""
    hasher = hashlib.sha256()
    with open(file_path, "rb") as f:
        while chunk := f.read(chunk_size):
            hasher.update(chunk)
    return hasher.hexdigest()


class ArtifactStore:
    """Content-addressable artifact store with metadata tracking."""

    def __init__(self, root: Path):
        self.root = root
        self.objects_dir = root / "objects"
        self.metadata_dir = root / "metadata"
        self.objects_dir.mkdir(parents=True, exist_ok=True)
        self.metadata_dir.mkdir(parents=True, exist_ok=True)

    def register(
        self,
        file_path: Path,
        artifact_type: str,
        source_run_id: Optional[str] = None,
        tags: Optional[dict] = None,
        parent_hashes: Optional[list] = None,
    ) -> ArtifactMetadata:
        """Register an artifact: hash it, copy to CAS, record metadata."""
        file_path = Path(file_path)
        sha = compute_hash(file_path)

        # Store the object using its hash as the filename
        # Use two-level directory structure (like Git) for filesystem scalability
        obj_dir = self.objects_dir / sha[:2]
        obj_dir.mkdir(exist_ok=True)
        obj_path = obj_dir / sha[2:]

        if not obj_path.exists():
            shutil.copy2(file_path, obj_path)

        metadata = ArtifactMetadata(
            artifact_type=artifact_type,
            sha256=sha,
            size_bytes=file_path.stat().st_size,
            created_at=datetime.now(timezone.utc).isoformat(),
            source_run_id=source_run_id,
            tags=tags or {},
            parent_hashes=parent_hashes or [],
        )

        # Persist metadata as JSON alongside the object
        meta_path = self.metadata_dir / f"{sha}.json"
        with open(meta_path, "w") as f:
            json.dump(metadata.__dict__, f, indent=2)

        return metadata

    def verify(self, sha: str) -> bool:
        """Verify that a stored artifact matches its content hash."""
        obj_path = self.objects_dir / sha[:2] / sha[2:]
        if not obj_path.exists():
            return False
        return compute_hash(obj_path) == sha

    def retrieve(self, sha: str, dest: Path) -> Path:
        """Retrieve an artifact by hash, verifying integrity."""
        if not self.verify(sha):
            raise ValueError(f"Artifact {sha[:12]}... failed integrity check")
        obj_path = self.objects_dir / sha[:2] / sha[2:]
        shutil.copy2(obj_path, dest)
        return dest


# Usage: register a trained model
store = ArtifactStore(Path("./artifact_store"))
meta = store.register(
    file_path=Path("outputs/model_v3.pt"),
    artifact_type="model",
    source_run_id="run_20260415_001",
    tags={"architecture": "transformer", "task": "property_prediction"},
    parent_hashes=["a1b2c3...", "d4e5f6..."],  # training data + config hashes
)
print(f"Registered: {meta.artifact_type} [{meta.sha256[:12]}...]")
print(f"Integrity check: {store.verify(meta.sha256)}")
Figure 47.1: A content-addressable artifact store with SHA-256 hashing. Each artifact is identified by its SHA-256 hash, stored in a two-level directory structure (like Git's object store), and accompanied by JSON metadata recording its type, provenance, and parent artifacts.

The two-level directory structure (objects/a1/b2c3d4...) prevents any single directory from containing millions of entries, which would degrade filesystem performance. Git popularized this pattern; we reuse it here because it typically scales well from hundreds to millions of artifacts.

Practical Example: Detecting Silent Data Corruption

A materials science team stored 50,000 crystal structure files on a shared Network File System (NFS) mount. After a storage migration, they discovered that 23 files had been silently truncated. Because they had registered every file in a content-addressable store, a batch verification script identified the corrupted files in seconds by comparing stored hashes against recomputed hashes. Without the registry, the corruption would have gone undetected until someone tried to use the damaged files in a downstream analysis, potentially months later, producing wrong results with no indication of the cause.

3. The Artifact Taxonomy

Not all artifacts are alike. A well-designed registry distinguishes artifact classes because they have different versioning characteristics, storage requirements, and lifecycle patterns. Table 47.1 summarizes the five primary artifact classes and their properties.

Artifact Class Examples Typical Size Versioning Tool Change Frequency
Code Training scripts, preprocessing pipelines, evaluation harnesses KB to MB Git High (many commits/day)
Data Raw datasets, processed features, train/test splits MB to TB DVC, LakeFS Low (new versions on acquisition or reprocessing)
Models Trained weights, checkpoints, Open Neural Network Exchange (ONNX) exports MB to GB MLflow Model Registry, DVC Medium (new version per training run)
Configs Hyperparameters, environment specs, pipeline definitions KB Git, MLflow params High (tuned frequently)
Results Metrics, plots, evaluation reports, predictions KB to MB MLflow metrics, Weights & Biases (W&B) High (one per run)
Table 47.1: The five primary artifact classes in an experiment registry. Each class has different storage requirements and change patterns, which determine the appropriate versioning strategy.

The key design decision is where each artifact class lives. Code and configs are small and text-based; they belong in Git, where diff, blame, and branch operations provide powerful inspection tools. Data and models are large and binary; they belong in a content-addressable store (DVC, cloud object storage, or a dedicated artifact store like the one in Figure 47.1) with only their hashes tracked in Git. This separation is the core insight behind DVC's architecture: Git tracks .dvc pointer files (small text files containing hashes), while the actual data lives elsewhere.

Common Misconception

A frequent mistake is believing that versioning code with Git is sufficient for experiment reproducibility. In practice, the same code run against a different version of the training data, a different set of hyperparameters, or a different library environment will produce different results. Full reproducibility requires versioning all five artifact classes (code, data, models, configs, and results) together, so that every result can be traced back to the exact combination of inputs that produced it.

4. Versioning Code with Git

Git is the foundation of all experiment versioning. Every experiment should be associated with a specific Git commit, so that the exact code that produced a result can be recovered. The minimum viable practice is to tag each experiment run with the current commit hash:

"""Capture Git state at experiment time."""
import subprocess


def get_git_info() -> dict:
    """Capture current Git state for provenance tracking."""

    def run_git(args: list[str]) -> str:
        result = subprocess.run(
            ["git"] + args,
            capture_output=True, text=True, check=True
        )
        return result.stdout.strip()

    # Check for uncommitted changes
    status = run_git(["status", "--porcelain"])
    is_dirty = len(status) > 0

    info = {
        "commit_hash": run_git(["rev-parse", "HEAD"]),
        "commit_short": run_git(["rev-parse", "--short", "HEAD"]),
        "branch": run_git(["rev-parse", "--abbrev-ref", "HEAD"]),
        "commit_message": run_git(["log", "-1", "--format=%s"]),
        "commit_timestamp": run_git(["log", "-1", "--format=%cI"]),
        "is_dirty": is_dirty,
        "dirty_files": status if is_dirty else None,
    }

    if is_dirty:
        # Record a diff patch for dirty files so the exact state
        # can be reconstructed even from uncommitted changes
        info["dirty_diff"] = run_git(["diff", "HEAD"])

    return info


# At the start of every experiment run
git_info = get_git_info()
if git_info["is_dirty"]:
    print(f"WARNING: Running from dirty working tree ({git_info['commit_short']}+)")
    print(f"  Modified files: {git_info['dirty_files']}")
    print("  Consider committing before running experiments.")
else:
    print(f"Clean commit: {git_info['commit_short']} on {git_info['branch']}")
Figure 47.2: Capturing Git commit hash, branch, and dirty-tree status for provenance. The function records the full commit hash and any uncommitted modifications, flagging dirty working trees as a reproducibility hazard.
Warning: The Dirty Working Tree Problem

One of the most common reproducibility failures is running an experiment from a dirty Git working tree: the commit hash points to a previous state, but the actual code includes uncommitted modifications. The get_git_info() function above captures this case by recording both the dirty flag and the diff. The better practice is to enforce clean commits before experiment runs, either through a pre-run check that refuses to start on a dirty tree, or through an automatic "snapshot commit" that creates a temporary commit with all current changes.

5. Versioning Data with DVC

DVC extends Git's versioning model to large files. The core idea is simple: instead of storing a 10 GB dataset in Git (which would make the repository unusable), DVC stores a small .dvc pointer file in Git that contains the dataset's SHA-256 hash and storage location. The actual data lives in a configured remote storage backend (S3, GCS, Azure Blob, SSH, or a local directory).

# Initialize DVC in an existing Git repository
dvc init

# Configure a remote storage backend
dvc remote add -d myremote s3://my-bucket/dvc-store

# Track a large dataset with DVC
dvc add data/crystal_structures.parquet

# This creates data/crystal_structures.parquet.dvc (pointer file)
# and adds data/crystal_structures.parquet to .gitignore
cat data/crystal_structures.parquet.dvc
Figure 47.3: Initializing DVC in a Git repository and tracking a large Parquet dataset. The .dvc file is a small YAML pointer containing the hash; Git tracks the pointer while DVC manages the actual data in remote storage.

The .dvc file that Git tracks looks like this:

outs:
- md5: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
  size: 10737418240
  hash: md5
  path: crystal_structures.parquet
Figure 47.4: Structure of a DVC pointer file showing the MD5 hash, byte size, and path. Git tracks this small YAML file; the actual 10 GB dataset lives in remote storage. As of DVC 3.0+ (2023), the default hashing algorithm for new projects is SHA-256 rather than MD5, improving collision resistance; existing projects using MD5 continue to work, but new initializations produce hash: sha256 in their pointer files.

Now Git commits form a timeline of code and data versions. Checking out a previous commit restores both the code and the DVC pointer files, and dvc pull retrieves the corresponding data versions from remote storage. This gives us the time-travel property essential for reproducibility: any historical experiment state can be fully reconstructed.

"""Programmatic DVC operations for pipeline integration."""
import subprocess
import yaml
from pathlib import Path


class DVCManager:
    """Manage DVC-tracked artifacts programmatically."""

    @staticmethod
    def track(file_path: str) -> dict:
        """Add a file to DVC tracking and return its metadata."""
        subprocess.run(["dvc", "add", file_path], check=True)

        # Read the generated .dvc file to get the hash
        dvc_file = Path(f"{file_path}.dvc")
        with open(dvc_file) as f:
            dvc_meta = yaml.safe_load(f)

        return {
            "path": file_path,
            "hash": dvc_meta["outs"][0]["md5"],
            "size": dvc_meta["outs"][0]["size"],
            "dvc_file": str(dvc_file),
        }

    @staticmethod
    def push():
        """Push tracked data to remote storage."""
        subprocess.run(["dvc", "push"], check=True)

    @staticmethod
    def pull():
        """Pull tracked data from remote storage."""
        subprocess.run(["dvc", "pull"], check=True)

    @staticmethod
    def checkout(git_ref: str):
        """Restore code and data to a specific Git ref."""
        # First checkout the code (and .dvc pointer files)
        subprocess.run(["git", "checkout", git_ref], check=True)
        # Then pull the corresponding data versions
        subprocess.run(["dvc", "checkout"], check=True)


# Track a processed dataset
meta = DVCManager.track("data/processed/features.parquet")
print(f"Tracked {meta['path']} with hash {meta['hash'][:12]}...")
Figure 47.5: A DVCManager class wrapping DVC CLI operations for automated pipelines. The track method registers a file and returns its hash; checkout restores both code and data to a specific Git revision.
Library Shortcut: DVC Python API

The manual subprocess calls in Figure 47.5 demonstrate the mechanism, but DVC also provides a Python API (import dvc.api) that handles operations directly: dvc.api.get_url() retrieves artifact URLs, dvc.api.read() loads versioned files, and dvc.api.params_show() reads parameters for the current workspace. The API reduces the DVCManager class to roughly 5 lines per method and eliminates subprocess management.

6. Versioning Models with MLflow

DVC handles data versioning; MLflow, an open-source platform for managing the machine learning lifecycle, provides a Model Registry for lifecycle management of trained models. The registry assigns each model a name, tracks versions sequentially, and supports stage transitions (Staging, Production, Archived) that map to deployment workflows (as of 2024, MLflow 2.9+ has deprecated stage-based transitions in favor of model version aliases, which offer more flexible promotion semantics; the examples below work with both the legacy stages and the newer alias system). MLflow's experiment tracking logs parameters, metrics, and artifacts per run. Together, these features produce a complete record of how each model version was created.

"""Model versioning with MLflow's Model Registry."""
import mlflow
from mlflow.tracking import MlflowClient


def train_and_register(
    model_name: str,
    train_data_hash: str,
    config: dict,
    model,
    metrics: dict,
):
    """Train a model, log everything, and register in the Model Registry."""
    mlflow.set_tracking_uri("sqlite:///mlflow.db")
    mlflow.set_experiment("crystal_property_prediction")

    with mlflow.start_run() as run:
        # Log the provenance chain
        mlflow.set_tag("data_hash", train_data_hash)
        mlflow.set_tag("git_commit", get_git_info()["commit_hash"])

        # Log all hyperparameters
        mlflow.log_params(config)

        # Log evaluation metrics
        mlflow.log_metrics(metrics)

        # Log the model artifact with its signature
        mlflow.pytorch.log_model(
            model,
            artifact_path="model",
            registered_model_name=model_name,
        )

        run_id = run.info.run_id
        print(f"Run {run_id}: logged {len(config)} params, {len(metrics)} metrics")

    # The model is now registered; retrieve its version
    client = MlflowClient()
    versions = client.search_model_versions(f"name='{model_name}'")
    latest = max(versions, key=lambda v: int(v.version))
    print(f"Registered {model_name} v{latest.version}")

    return run_id, latest.version


# Example usage
run_id, version = train_and_register(
    model_name="CrystalPropertyPredictor",
    train_data_hash="a1b2c3d4...",
    config={"lr": 0.001, "epochs": 100, "hidden_dim": 256, "dropout": 0.1},
    model=trained_model,       # Your PyTorch model
    metrics={"mae": 0.042, "r2": 0.94, "rmse": 0.061},
)
Figure 47.6: Registering a PyTorch model in MLflow's Model Registry with full provenance. Each training run logs the data hash, Git commit, hyperparameters, and evaluation metrics alongside the model weights, creating a searchable version history.

7. Configuration Management

Versioning code with Git and data with DVC accounts for two of the five artifact classes, but the parameters that govern how code processes data are equally critical to reproducing a result.

The Lost Parameter Problem

Hyperparameters and configuration files are the most frequently changed and the most frequently lost artifacts. A training script might accept dozens of parameters (learning rate, batch size, architecture choices, data splits, augmentation settings), and the difference between a successful and failed experiment often comes down to a single parameter value that was changed in the command line but never recorded.

The solution is to make configuration files the primary interface to experiments, never raw command-line arguments. Every experiment run reads from a structured configuration file (YAML, TOML, or JSON); the file is versioned in Git; and the registry stores the content hash of the configuration alongside the run record.

"""Structured configuration management for experiments."""
import hashlib
import json
from dataclasses import dataclass, asdict
from pathlib import Path


@dataclass
class ExperimentConfig:
    """Typed, hashable experiment configuration."""
    # Data parameters
    dataset_path: str
    dataset_version: str       # DVC hash of the dataset
    train_split: float = 0.8
    random_seed: int = 42

    # Model parameters
    architecture: str = "transformer"
    hidden_dim: int = 256
    num_layers: int = 4
    dropout: float = 0.1

    # Training parameters
    learning_rate: float = 1e-3
    batch_size: int = 64
    max_epochs: int = 100
    early_stopping_patience: int = 10

    # Environment
    device: str = "cuda"
    num_workers: int = 4

    def content_hash(self) -> str:
        """Compute a deterministic hash of this configuration."""
        # Sort keys for deterministic serialization
        config_bytes = json.dumps(
            asdict(self), sort_keys=True
        ).encode("utf-8")
        return hashlib.sha256(config_bytes).hexdigest()

    def save(self, path: Path):
        """Save configuration to a JSON file."""
        with open(path, "w") as f:
            json.dump(asdict(self), f, indent=2, sort_keys=True)

    @classmethod
    def load(cls, path: Path) -> "ExperimentConfig":
        """Load configuration from a JSON file."""
        with open(path) as f:
            return cls(**json.load(f))


# Create and hash a configuration
config = ExperimentConfig(
    dataset_path="data/crystals.parquet",
    dataset_version="a1b2c3d4e5f6...",
    learning_rate=0.0005,
    num_layers=6,
)
config_hash = config.content_hash()
print(f"Config hash: {config_hash[:12]}...")

# Two configs with the same values produce the same hash
config2 = ExperimentConfig(
    dataset_path="data/crystals.parquet",
    dataset_version="a1b2c3d4e5f6...",
    learning_rate=0.0005,
    num_layers=6,
)
assert config.content_hash() == config2.content_hash()  # Deterministic
Figure 47.7: A typed ExperimentConfig dataclass with deterministic content hashing. The content_hash() method uses sort_keys=True to ensure identical parameter values always produce the same hash regardless of field ordering.

Checkpoint

So far: we have established four artifact classes with dedicated versioning tools: code in Git (Section 4), data in DVC (Section 5), models in MLflow (Section 6), and configurations as hashed dataclasses in Git (Section 7). The remaining piece is the runtime environment that ties them together.

8. Environment Reproducibility with Docker

Even with code, data, and configurations pinned to exact hashes, one source of variation remains: the runtime environment itself.

Code, data, and config versioning capture what the experiment does, but not where it runs. Library version differences, CUDA toolkit versions, operating system patches, and even compiler flags can produce different results from identical code. Docker containers capture the complete runtime environment in a reproducible image.

# Dockerfile for reproducible experiment environments
FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime

# Pin exact library versions for reproducibility
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt

# Copy experiment code
COPY src/ /app/src/
COPY configs/ /app/configs/

WORKDIR /app

# Record the image hash for provenance
RUN echo "Build timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /app/BUILD_INFO
RUN pip freeze > /app/FROZEN_REQUIREMENTS.txt

ENTRYPOINT ["python", "-m", "src.train"]
Figure 47.8: Dockerfile pinning the PyTorch base image, freezing pip dependencies, and recording build metadata. The resulting image hash becomes a versioned artifact in the registry.

The Docker image hash becomes another artifact in the registry, linked to the runs that used it. Combined with the Git commit hash (for code), DVC hash (for data), and config hash (for parameters), we now have a four-part identifier that fully specifies an experiment:

$$\text{Experiment} = (\text{code}_{\text{git}}, \; \text{data}_{\text{dvc}}, \; \text{config}_{\text{sha}}, \; \text{env}_{\text{docker}})$$

Any two experiments that share this four-tuple should, in principle, produce the same results (subject to hardware-level non-determinism such as GPU floating-point operation ordering, thread scheduling, and non-deterministic library calls, which we discuss in Section 47.3).

9. The Artifact DAG

With all five artifact classes versioned and their identities pinned to content hashes, the next question is how to record the relationships between them so that any result can be traced back through the chain of inputs and transformations that produced it. We can now assemble the artifact DAG: a directed acyclic graph where nodes are artifacts and edges represent "produced by" relationships. Each edge passes through a run (an execution event), so the full graph is technically a bipartite DAG, where "bipartite" means the graph contains two distinct node types (artifacts and runs) with edges only between types, never within a type. Figure 47.10 illustrates a two-stage pipeline in this structure: raw data flows through a preprocessing run to produce processed data, which then flows through a training run to produce a model. Figure 47.1.1 illustrates Artifact DAG showing bipartite graph of artifacts and runs.

Artifact DAG showing bipartite graph of artifacts and runs
Figure 47.1.1: The artifact DAG as a bipartite graph of artifacts (blue rectangles with content hashes) and runs (orange hexagons with run IDs). Directed edges trace the complete lineage from raw inputs through processing and training runs to final outputs, enabling both provenance queries and incremental recomputation.
Raw Data sha: 3f7a... Config sha: c91b... Preprocess run_001 Processed sha: 8e2d... Hyperparams sha: f4a1... Train run_002 Model sha: d5c7... Artifact Run Config
Figure 47.10: A bipartite artifact DAG for a two-stage experiment pipeline. Rectangles represent versioned artifacts (identified by content hash), rounded capsules represent execution runs. Edges flow from input artifacts into runs and from runs into output artifacts, forming a complete lineage graph. Tracing backward from the Model node recovers every artifact and run involved in its production.
"""Artifact DAG construction and traversal."""
from dataclasses import dataclass, field
from collections import defaultdict


@dataclass
class RunRecord:
    """Record of a single experiment run."""
    run_id: str
    git_commit: str
    config_hash: str
    docker_image: str
    input_hashes: list[str]    # Hashes of input artifacts
    output_hashes: list[str]   # Hashes of output artifacts
    start_time: str
    end_time: str
    metrics: dict = field(default_factory=dict)


class ArtifactDAG:
    """Directed acyclic graph of artifacts and the runs that connect them."""

    def __init__(self):
        self.runs: dict[str, RunRecord] = {}
        self.produced_by: dict[str, str] = {}   # artifact_hash -> run_id
        self.consumed_by: dict[str, list[str]] = defaultdict(list)

    def add_run(self, run: RunRecord):
        """Register a run and its artifact relationships."""
        self.runs[run.run_id] = run
        for out_hash in run.output_hashes:
            self.produced_by[out_hash] = run.run_id
        for in_hash in run.input_hashes:
            self.consumed_by[in_hash].append(run.run_id)

    def trace_lineage(self, artifact_hash: str) -> list[RunRecord]:
        """Trace the complete lineage of an artifact back to raw inputs."""
        lineage = []
        visited = set()
        stack = [artifact_hash]

        while stack:
            current = stack.pop()
            if current in visited:
                continue
            visited.add(current)

            if current in self.produced_by:
                run_id = self.produced_by[current]
                run = self.runs[run_id]
                lineage.append(run)
                stack.extend(run.input_hashes)

        return lineage

    def regeneration_plan(self, artifact_hash: str) -> list[RunRecord]:
        """Produce an ordered list of runs needed to regenerate an artifact."""
        lineage = self.trace_lineage(artifact_hash)
        # Reverse to get execution order (inputs first)
        return list(reversed(lineage))


# Build an artifact DAG from experiment history
dag = ArtifactDAG()

# Stage 1: Data preprocessing
dag.add_run(RunRecord(
    run_id="preprocess_001",
    git_commit="abc123",
    config_hash="cfg_aaa",
    docker_image="exp:v1.0",
    input_hashes=["raw_data_hash"],
    output_hashes=["processed_data_hash"],
    start_time="2026-04-15T10:00:00Z",
    end_time="2026-04-15T10:05:00Z",
))

# Stage 2: Model training
dag.add_run(RunRecord(
    run_id="train_001",
    git_commit="abc123",
    config_hash="cfg_bbb",
    docker_image="exp:v1.0",
    input_hashes=["processed_data_hash"],
    output_hashes=["model_hash"],
    start_time="2026-04-15T10:10:00Z",
    end_time="2026-04-15T12:30:00Z",
    metrics={"val_loss": 0.023, "val_accuracy": 0.97},
))

# Trace lineage of the trained model
lineage = dag.trace_lineage("model_hash")
print(f"Model lineage: {len(lineage)} runs")
for run in lineage:
    print(f"  {run.run_id}: {len(run.input_hashes)} inputs -> "
          f"{len(run.output_hashes)} outputs")

# Generate a regeneration plan
plan = dag.regeneration_plan("model_hash")
print(f"\nRegeneration plan ({len(plan)} steps):")
for i, run in enumerate(plan, 1):
    print(f"  Step {i}: {run.run_id} (commit {run.git_commit[:7]})")
Figure 47.9: Building a two-stage artifact DAG and querying it for lineage and regeneration. The trace_lineage method walks backward from any artifact to discover every run and input involved in its production. The regeneration_plan method reverses this trace into a forward execution order for reproducing the artifact from scratch.
Key Insight: The DAG Enables Incremental Recomputation

The artifact DAG is not just a provenance record; it is a computation graph. Given the DAG and a content-addressable store, you can identify exactly which artifacts need to be recomputed when an input changes. If you modify the preprocessing code but not the raw data, only the preprocessing and downstream stages need re-execution; the raw data retrieval step can be skipped. This is the same principle behind build systems like Make and Bazel (tools that track file dependencies and re-execute only the steps whose inputs have changed), applied to scientific experiments. DVC pipelines (dvc.yaml) implement this pattern directly, as we will see in Section 47.3.

10. Tool Comparison

Content-addressable storage, artifact taxonomy, and DAG-based lineage define what a registry must do; the remaining question is which existing tools implement these ideas so you do not have to build everything from scratch.

Table 47.2 compares the major experiment tracking and artifact versioning tools available as of 2026. No single tool covers all requirements; production registries typically combine two or three.

Capability MLflow W&B DVC Git + CAS DAGsHub
Experiment tracking Yes (core) Yes (core) Via params/metrics Manual MLflow integration
Large artifact storage Artifact store Artifact store Yes (core) Custom DVC integration
Model registry Yes (core) Model registry No No MLflow integration
Pipeline DAG No No Yes (dvc.yaml) No DVC integration
Data versioning Limited Artifact versioning Yes (core) Manual hashing DVC integration
Self-hosted option Yes (open source) Enterprise only Yes (open source) Yes Cloud + self-hosted
Provenance standard Custom Custom Custom Custom Custom
Table 47.2: Comparison of experiment tracking and artifact versioning tools across seven capabilities. The "Provenance standard" row highlights a gap: none of these tools natively supports W3C PROV or OpenLineage, which we address in Section 47.2.

The combination we build in Section 47.3 uses Git for code, DVC for data and pipeline definitions, and MLflow for experiment tracking and model registration. This combination is open-source, self-hostable, and covers all five artifact classes with content-addressable verification at every stage.

Library Shortcut: Weights & Biases in Three Lines

If you prefer a managed solution over the Git + DVC + MLflow stack, Weights & Biases (W&B) provides experiment tracking, artifact versioning, and model registry in a single API. A minimal integration takes three lines: wandb.init(project="my_project"), wandb.config.update(config_dict), and wandb.log(metrics_dict). W&B handles artifact hashing, storage, and UI automatically. The tradeoff is vendor lock-in: your provenance data lives on W&B's servers (or your enterprise deployment) rather than in portable open formats. For teams that prioritize speed of setup over data sovereignty, this is often the right choice.

Research Frontier

The Croissant metadata format, introduced by MLCommons in 2024 (Akhtar et al., "Croissant: A Metadata Format for ML-Ready Datasets," published at KDD 2024), pushes artifact registries toward cross-platform interoperability. Croissant defines a JSON-LD (JSON for Linking Data), a W3C standard for embedding structured metadata in JSON using web identifiers, vocabulary that describes dataset structure, provenance, licensing, and responsible-AI properties in a machine-readable schema. Major platforms including Hugging Face, Kaggle, and OpenML have adopted Croissant as a standard metadata layer, enabling registries to exchange dataset descriptions without proprietary lock-in. This complements the per-artifact hashing covered in this section by adding a semantic layer: not just what the artifact contains (its hash), but what the artifact means (its fields, splits, intended uses, and known limitations) in a format that any compliant tool can parse.

Try It: Build a Mini Artifact Registry

Build a working content-addressable artifact store and use it to track a small experiment, using only the Python standard library. (1) Create a directory called my_registry/ with subdirectories objects/ and metadata/. Write a Python function register(filepath) that computes the SHA-256 hash of any file, copies it into objects/ using the first two hex characters as a subdirectory name (e.g., objects/a1/b2c3d4...), and saves a JSON metadata file in metadata/ recording the hash, original filename, file size, and a timestamp. (2) Create three small text files simulating experiment artifacts: a config file (config.json with a learning rate and batch size), a "dataset" file (a CSV with 10 rows of dummy data), and a "results" file (a JSON with dummy metrics). Register all three. (3) Modify one value in config.json (change the learning rate) and register it again. Verify that the new registration produces a different hash and that both versions coexist in the store. (4) Write a verify(sha256) function that re-reads the stored file, recomputes its hash, and confirms it matches. Run it on all registered artifacts. (5) Write a lineage(sha256) function that reads the metadata JSON for a given hash and prints its parent artifact hashes. Manually set the results file's metadata to list the config and dataset hashes as parents, then call lineage() to reconstruct which inputs produced the result.

Exercise 47.1.1

Suppose you register two files in a content-addressable store: config_v1.json (containing {"lr": 0.001, "batch_size": 32}) and config_v2.json (containing {"batch_size": 32, "lr": 0.001}). The byte contents of these two files differ because the key order is different. Will they receive the same SHA-256 hash or different hashes? What does this imply for the ExperimentConfig.content_hash() method shown in Figure 47.7, and why does it use sort_keys=True?

Hint

SHA-256 operates on raw bytes, not on semantic content. Two JSON files with identical key-value pairs but different key ordering are different byte sequences, so they produce different hashes. The content_hash() method avoids this problem by serializing through json.dumps(..., sort_keys=True), which enforces a canonical key order before hashing. Without sort_keys=True, logically identical configurations could receive different hashes, causing the registry to treat them as distinct artifacts.

Step-Through: Content-Addressable Registration

Trace through the ArtifactStore.register() method with a tiny file whose contents are the 5 bytes hello.

Step 1: Compute SHA-256 of hello. Result: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 (64 hex characters).

Step 2: Determine the storage path. First two hex characters are 2c, remainder is f24dba5fb0a30e.... The object is stored at objects/2c/f24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.

Step 3: Check whether obj_path already exists. It does not (first registration), so copy the file to that path.

Step 4: Create an ArtifactMetadata object with sha256="2cf24dba...", size_bytes=5, and the current UTC timestamp.

Step 5: Write the metadata JSON to metadata/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.json.

Deduplication check: Register a second file also containing hello. At Step 3, obj_path.exists() returns True, so the copy is skipped. The store never holds two copies of identical content.

Real-World Application: Hugging Face Hub

The Hugging Face Hub uses content-addressable storage (Git Large File Storage (LFS) with SHA-256 pointers) to version over one million public models and datasets as of early 2025. Every model revision is identified by its commit hash, and each large file (weights, tokenizer binaries) is tracked by its content hash in LFS pointer files. This design lets any user pin a model to an exact revision (revision="a1b2c3d" in the transformers API), guaranteeing byte-identical weights across all downloads, while the Hub deduplicates storage across forks that share identical weight files.

The \$10 Million Sticky Note

In 2019, a pharmaceutical company reportedly spent over six months attempting to reproduce a promising drug-interaction model whose hyperparameters had been recorded only in a researcher's personal notes, which were discarded after the researcher left the company. The inability to reproduce the result delayed a clinical trial by nearly a year. This incident, reportedly referenced in regulatory discussions on computational reproducibility, is one of several cases that motivated the pharmaceutical industry's adoption of formal experiment registries. The total cost of the delay was estimated at over \$10 million, making it perhaps the most expensive missing configuration file in history.

Lab: Hash Collision and Integrity Verification

Goal: Build intuition for how content-addressable storage detects corruption and handles large artifact collections.

Tools needed: Python 3.10+ (standard library only: hashlib, os, json, random, time).

Procedure (20 minutes): (1) Generate 1,000 random binary files of varying sizes (1 KB to 1 MB) and register each in the ArtifactStore from Figure 47.1. Record the wall-clock time for registration. (2) Verify all 1,000 artifacts using store.verify(). Record the verification time. (3) Deliberately corrupt 10 random artifacts by flipping a single byte in each stored object file. Run verification again and confirm that exactly those 10 fail. (4) Duplicate 50 of the original files (copy them under new filenames) and register the duplicates. Observe that no new objects appear in the store (deduplication).

What to vary: Try replacing SHA-256 with MD5 (hashlib.md5) and measure the speed difference. Try increasing the collection to 10,000 files and observe how the two-level directory structure keeps directory listing times constant.

What to observe: (a) The hash computation time scales linearly with total bytes, not file count. (b) Single-byte corruption is always detected. (c) Deduplication is automatic and requires no special logic beyond the "if not exists" check.

What's Next

We have built the storage and versioning layer for individual artifacts. But knowing what exists is not the same as knowing how things relate. Section 47.2: Data Lineage and Provenance Standards formalizes the relationships between artifacts using the W3C PROV data model (a W3C standard for representing provenance information as entities, activities, and agents) and the OpenLineage specification, giving us a portable, queryable representation of the entire experiment history.