Skip to content

Bike-demand reference project

The SDK repository includes a self-contained consumer project at examples/bike-demand-service. It dogfoods OCLP on a public UCI Bike Sharing dataset. MLflow remains an optional SDK extra (oclp[mlflow]), rather than a core dependency.

It is a reference project, not an SDK feature or a prescribed architecture. The application owns its computation boundaries, storage, ML workflow, and contracts. OCLP makes its durable observations interoperable.

Project layout

Area Responsibility OCLP role
data.py Downloads the UCI source and prepares leakage-safe temporal features. Declares a CSV Artifact acquisition and the feature-preparation Computation.
modeling.py Declares the training-plan Artifact, trains CatBoost folds and final model, evaluates, and scores holdout data. Declares reusable model boundaries.
environment.py Resolves local OCLP, MLflow, and payload directories. Local-only execution environment; not a durable run input.
runner.py Declares and coordinates one model-training run. Uses SDK @run / observe_run(...), passes persisted outputs into training, declares the final cross-computation ArtifactSet, and configures the optional SDK MLflow adapter.
oclp.publishing Writes immutable payload bytes, hashes them, and persists canonical records. Generic local persistence; no bike-specific policy.

All generated data is local and ignored by Git:

data/
  runs/<run-id>/       # payload bytes: CSV, CatBoost models, JSON reports, PNG charts
  oclp-0.3/            # canonical OCLP records and producer catalog
  mlflow/              # local MLflow SQLite metadata and its own artifacts

What it does

The batch milestone executes a time-ordered CatBoost regression workflow:

UCI source CSV
  -> feature table + temporal-fold definition
  -> three fold-training Executions
  -> candidate evaluation and Evidence
  -> final model
  -> model-release ArtifactSet
  -> offline holdout predictions and Evidence
  -> diagnostic PNG charts for validation quality and holdout forecasting
  -> release-inference smoke-test run

The @run declaration on run_bike_training lets the SDK derive the same UUID-based profiles.run binding for every real Execution in the batch. Cyclops uses that shared run context without adding a synthetic root Execution or an orchestration edge. The data-derivation graph remains explicit: Artifacts and ArtifactSets flow into Executions, which produce new Artifacts or ArtifactSets. OCLP's graph and execution-acceptance validators run at the end of the demo.

After the batch run publishes its model-release ArtifactSet and manifest, run_demo opens a second release-verification run with its own fresh UUID. It resolves the released model only through that manifest, persists one fixed request and response, and requires Prediction response validation Evidence to pass. The released model Artifact connects it to training, but it is not a synthetic child Execution of the training workflow. The smoke test calls the same decorated prediction callable that FastAPI uses; it does not start an HTTP server.

Each reusable computation is declared beside its actual Python callable with @oclp.computation. At run time the demo selects its observed Git source with observe_run(...); the SDK materializes each callable's Computation record and derives its locator from the function rather than copying a hand-maintained string into the runner.

How this project uses OCLP

1. Source acquisition declares an Artifact boundary

Most of the example still uses explicit Artifact materialization for rich outputs. The UCI fetch is not a derived Computation: it is a network acquisition boundary. Its decorator declares the source Artifact's CSV representation, while the SDK derives facts about the materialized bytes. @csv_artifact makes the returned DataFrame a persisted CSV source snapshot in an active OclpRun, then returns a CsvArtifact handle. The downstream feature-preparation Computation receives a verified pandas DataFrame loaded by the SDK's CSV-to-pandas adapter.

from oclp import csv_artifact


@csv_artifact(
    name="UCI Bike Sharing hourly source snapshot (CSV)",
    index=False,
    lineterminator="\n",
)
def download_source_csv(dataset_id: int = UCI_BIKE_SHARING_DATASET_ID) -> pd.DataFrame:
    dataset = fetch_ucirepo(id=dataset_id)
    ...
    return frame

The decorated call returns a CsvArtifact handle, not an OCLP proxy. The function body remains ordinary pandas code and runs only inside an active OclpRun, where a store exists to persist its immutable payload. It creates no Computation, Execution, or standard execution Event.

The next boundary retains its pd.DataFrame parameter and ordinary feature logic. The runtime receives the resolved CSV Artifact handle, verifies its digest, loads it through PandasCsvAdapter, and records the exact reference on the Execution input port before calling the function body.

Its ordinary fold_count: int = 3 argument is not an input Artifact. The @computation decorator infers it as an optional Computation parameter with JSON Schema {"type": "integer", "default": 3} and records the effective value on the feature-preparation Execution. The same rule captures train_fold(..., fold_number: int) and train_final_model(..., training_window: Literal[...]). The SDK itself stages the returned CatBoost model while materializing CatBoostModelArtifact, so no local model path belongs in the portable computation contract. The UCI client returns tabular Python objects rather than the original remote response bytes, so this Artifact is intentionally a reproducible CSV snapshot of the fetched table—not a claim to preserve the provider's exact download. The feature-preparation boundary remains a useful example of an explicit multi-output contract:

@computation(
    name="Prepare bike demand features",
    inputs={
        "source_snapshot": CsvArtifact,
        "training_plan": JsonArtifact,
    },
    outputs={
        "features": CsvArtifact(name="Bike demand features"),
        "fold_definition": JsonArtifact(name="Temporal fold definition"),
        "feature_contract": JsonArtifact(name="Feature contract"),
    },
)
def prepare_features(
    source_snapshot: pd.DataFrame,
    training_plan: dict[str, object],
) -> dict[str, object]:
    # Ordinary domain logic: normalize, remove leakage, and create time folds.
    ...

This static-declaration pattern is used by data.py, modeling.py, and runner.py. It avoids the drift-prone alternative of maintaining a separate table of string locators.

Source-format factory: CSV, Parquet, and table JSON

The default batch pipeline stays on CSV. Separately, data.py includes a small download_source_artifact() factory that dogfoods three SDK-owned representation decorators against the same UCI fetch:

Factory choice Decorator Persisted format Returned handle pandas adapter
"csv" @csv_artifact text/csv CsvArtifact PandasCsvAdapter
"parquet" @parquet_artifact application/vnd.apache.parquet ParquetArtifact PandasParquetAdapter
"json" @json_artifact(serialization="pandas-table") pandas orient="table" application/json JsonArtifact PandasJsonTableAdapter

Each factory call creates one new Artifact record UUID. The formats have different payload bytes and therefore different Artifact digests. The application can retain dataset_id as an annotation when that source identity needs to be queryable; it is not encoded into the record UUID.

The source-format contract test defines its own test-local input-only Computation. Passing it each of the three handles proves that the SDK verifies the payload digest, deserializes it using the registered adapter, and passes an equivalent normalized DataFrame to a normal Python function. That probe is not part of the bike-demand application or any production run graph.

2. The SDK observes one declared run and materializes its source-bound Computations

runner.py declares its actual workflow with @run, resolves one Git source basis for the checkout, and activates it once with observe_run(...). The SDK then materializes and publishes one source-bound Computation record for each observed decorated callable in that run. The SDK derives implementation.locator directly from the function—for example, bike_demand_service.data.prepare_features.

The optional MLflow mirror is part of that run declaration, rather than a bootstrap-only observer:

@run(
    name="Bike demand model training",
    adapters=(
        MlflowAdapter(experiment_name="oclp-bike-demand-service"),
    ),
    required_evidence_policy="raise",
)
def run_bike_training(...): ...
environment = DemoEnvironment.default()

# The publisher is application bootstrap; a dirty checkout gets an exact,
# durable source overlay before the SDK observes any Computation.
source = source_from_git_checkout(
    environment.project_root,
    path="examples/bike-demand-service/src/bike_demand_service",
)
if isinstance(source, GitSource) and source.dirty:
    source = capture_git_source_overlay(
        environment.project_root,
        source=source,
        publisher=publisher,
        name="Bike-demand training source overlay",
        relative_path=f"source-overlays/{materialization_id}",
    )

with observe_run(
    run_bike_training,
    publisher=publisher,
    source=source,
) as observed:
    run_bike_training(
        observed=observed,
        materialization_id=materialization_id,
        fold_count=3,
        temporal_validation_rmse_max=250,
    )

There is no bike-specific Computation registry and no pre-publication pass. The runtime records exactly the Computations that actually execute.

3. Decorated feature preparation owns its ordinary outputs

The SDK's LocalArtifactPublisher is intentionally generic. It writes bytes to a configured payload root, computes their content digest, writes canonical record JSON, and indexes records in the producer-owned local catalog. It does not choose a project's Artifact ID, display name, path, schema, or profile.

prepare_features returns a plain mapping with features, fold_definition, and feature_contract keys matching its three output ports. The feature table is a single immutable CSV Artifact, so it does not pretend to be a DatasetSnapshot. Each declaration specifies its display name, logical key, payload path, representation, and any schema metadata beside the function that produced it. The SDK writes and binds all three Artifacts; the runner does not construct them.

The source DataFrame is similarly persisted by its Artifact-decorated acquisition function. Within one active OclpRun, the runner passes ordinary returned values to its next decorated Computation:

with LocalArtifactPublisher(...) as publisher:
    with observe_run(
        run_bike_training,
        publisher=publisher,
        source=source,
    ) as observed:
        training_plan = create_training_plan(
            materialization_id=materialization_id,
            fold_count=3,
        )
        source_snapshot = download_source_csv()
        prepared = prepare_features(source_snapshot, training_plan)
        feature_table = prepared["features"]
        folds = prepared["fold_definition"]
        train_fold(feature_table, folds, fold_number=1)

The SDK recognizes each exact in-memory output value and reuses its already published Artifact binding when it records the next Execution. Thus the call above records the features and fold_definition Artifacts as the exact inputs to train_fold without the runner extracting handles merely to wire the graph. Candidate evaluation likewise receives the raw prediction DataFrames, final-model training receives the raw feature table and configuration, and holdout scoring receives the raw final model and feature table.

Passing an ArtifactHandle remains useful when an application deliberately wants a serialization round trip: the SDK verifies the payload bytes and loads them through the downstream adapter before calling the function. That is not required for same-process lineage. Across processes or runs, object identity does not exist, so a handle or a resolved Artifact reference is required.

@run declares the five selected child outputs as the Bike demand CatBoost release ArtifactSet. Once the successful run completes, the SDK resolves those exact handles and materializes a separate release-manifest.json sidecar from their available upstream OCLP record closure. It carries the exact ArtifactSet UUID reference and therefore is not a sixth member: including it in the set would create a self-content cycle. This remains direct collection publication, not a fake package Computation: it has no locator, Execution, or standard execution Events.

training_plan is an input Artifact rather than a fake workflow output. Its decorator persists the configuration as JSON and its exact reference is bound to prepare_features. This makes the fold-count choice a real, portable input to the computation that uses it.

4. The SDK mirrors observations to MLflow

MlflowAdapter observes the records the runtime has already published. It opens one MLflow run for the OCLP run, logs canonical record JSON and UUID cross-links, mirrors typed Execution parameters and numeric Evidence details, and uploads model payloads by default. Repeated fold parameters are scoped by their exact Execution UUID because MLflow itself does not allow a parameter value to change in one run. Computation-local @mlflow declarations extract numeric fields from the fold, candidate-evaluation, and holdout JSON Artifacts. The outer workflow @mlflow(run_parameters=...) declaration mirrors the explicit training context—materialization ID, fold count, validation threshold, and dataset ID—under the separate workflow.* namespace. Those values are an MLflow-only application projection, not OCLP Execution parameters. The runner does not retrieve OCLP references merely to make MLflow work.

@mlflow uses exact local output ports—not a name-based search for any output called metrics. The prefix makes the MLflow keys readable. For repeated train_fold Executions, the declaration MlflowMetrics(output_port="metrics", prefix="temporal-fold", dimensions=("fold_number",)) adds the declared Execution parameter to the key, producing names such as temporal-fold.fold_number-2.rmse and preventing MLflow's immutable metric namespace from conflating folds. Candidate evaluation and holdout scoring run once, so their declarations need no dimensions.

Each mirrored model lives below its exact Artifact UUID in MLflow. This keeps the three temporal-fold models distinct even though they share the same human-readable name and model.cbm filename. The model records also carry a fold_number annotation, resolved from each train_fold call, so a person can identify the temporal split without following the Artifact UUID back through its Execution.

Feature preparation persists its data-volume metrics—source, prepared, training, and holdout row counts—as its own JSON Artifact. Its local @mlflow(metrics=...) declaration mirrors those numeric fields. The training workflow therefore contains no MLflow adapter lookup or manual MLflow logging; the adapter observes the real declared outputs.

Non-model payloads remain in OCLP unless the owning Computation explicitly selects output ports with @mlflow(payloads=("report",)). This avoids silently copying large datasets. MLflow failures produce an adapter-failed OCLP Event with a Diagnostic by default; strict=True makes mirroring fail the application run.

The two small diagnostic charts are ordinary BytesArtifact outputs. Each declares media_type="image/png" and suffix="png", and its owning Computation opts its exact chart output into the MLflow mirror:

@mlflow(payloads=("chart",))
@computation(
    name="Chart holdout demand forecast",
    inputs={"predictions": CsvArtifact},
    outputs={
        "chart": BytesArtifact(
            name="Holdout demand forecast chart",
            media_type="image/png",
            suffix="png",
        )
    },
)
def chart_holdout_demand_forecast(predictions: pd.DataFrame) -> dict[str, bytes]:
    return {"chart": render_png(predictions)}

They consume the actual persisted validation or holdout prediction tables and therefore have normal Artifact → Execution → Artifact lineage. They are useful operational diagnostics, but are deliberately not members of the model-release ArtifactSet: model serving does not depend on them.

The workflow’s required_evidence_policy="raise" is separate from the MLflow adapter. A failed temporal-quality or holdout Evidence gate still produces its outputs, Evidence records, and failed terminal Execution, then raises before the runner can promote a model or continue the smoke-test flow.

The SDK observes temporal-fold training, candidate evaluation, final-model training, and holdout scoring and materializes their declared outputs. @evidence evaluators run automatically against a same-named returned output: temporal_validation_quality(evaluation), for example, evaluates the "evaluation" entry. The runtime publishes Evidence before the terminal Event and marks the Execution failed when a required evaluator fails. Release publication is deliberately outside the automatic Computation path but is declared on the real @run boundary. The SDK resolves the named, cross-computation collection from exact handles only after a successful run; it does not invent an Execution or standard execution Events. A retry that represents a new release receives a new run UUID, which creates a distinct ArtifactSet ID, digest, and manifest Artifact. The runner supplies the concise manifest name; the SDK does not derive it from the run.

After that collection exists, run_demo opens a separate observed run for the release inference smoke test. It calls load_release_manifest(release_manifest_path), persists a deterministic request Artifact, and invokes predict_bike_demand(...) using the resolved ArtifactSetHandle. Its required Prediction response validation Evidence ensures a finite prediction with a request and release identity. Failure raises from the smoke run without changing the already immutable training release.

5. Dataset, release, and quality concepts use the records that fit them

The feature table is a normal immutable CSV Artifact. A fold-training Execution directly accepts that Artifact and the temporal-fold JSON document; final training directly accepts the same feature table and its JSON configuration.

The selected model release is an ArtifactSet, not another opaque model file. It has named members for the final CatBoost model, feature contract, temporal evaluation report, training configuration, and input feature table. The runner declares that ArtifactSet from the five exact child output ports for release consumers, and the SDK writes a release-manifest.json sidecar. That sidecar carries the exact ArtifactSet reference, record body, and resolved upstream provenance closure without copying model or dataset bytes. Its set members remain individually addressable immutable Artifacts. The offline holdout scorer dogfoods the lower-level inputs directly—the published model file Artifact and the feature-table CSV—and publishes prediction and metrics Artifacts through its declared output mapping.

The evaluation Computation directly requires the decorated temporal_validation_quality evaluator. The SDK binds that evaluator to the source observed for the Execution and records the same exact binding in its Evidence. Its quality gate is therefore both Evidence and a success condition for that Execution; a failed gate produces a terminal failed status rather than a misleading successful completion. The detailed numeric report remains a separate Artifact. The evaluator binding is inferred from its parameter name, so the runner does not call evaluate_evidence():

@evidence(name="Temporal validation quality")
def temporal_validation_quality(evaluation) -> str:
    return "pass" if evaluation["rmse"] <= 250 else "fail"


@computation(
    ...,
    outputs={"evaluation": JsonArtifact(name="Candidate evaluation")},
    requires=(temporal_validation_quality,),
)
def evaluate_candidate(...) -> dict[str, object]:
    return {"evaluation": evaluation}

The detailed fold and holdout metrics remain JSON Artifact payloads, where they can be inspected or used by later computations. Evidence makes the specific pass/fail decision visible in a generic way. At the end of a run, the demo also calls validate_execution_acceptance() alongside the derivation and execution-hierarchy validators.

Run it locally

The example has an isolated dependency environment and uses the adjacent SDK checkout by editable path.

cd examples/bike-demand-service
uv sync
uv run bike-demand run --materialization-id bike-demand-first-run

All downloaded data, immutable payloads, OCLP records, the DuckDB catalog, and MLflow's local data stay under the example's ignored data/ directory. A run prints the OCLP record directory, model-release ArtifactSet ID, release-smoke Execution and response Artifact IDs, and MLflow tracking URI.

To inspect the durable records with Cyclops, point its API at the generated directory:

oclp-explorer --oclp-dir "$(pwd)/data/oclp-0.3"

OCLP and MLflow have separate responsibilities

MLflow is intentionally a parallel experiment-tracking view, not an OCLP record store or Artifact registry.

Concern OCLP MLflow in this demo
Immutable model/data/prediction bytes Canonical Artifacts at local file locations Model payloads mirrored by default; other payloads are opt-in
Exact inputs and outputs Digest-bound Execution references Linked through UUID tags and canonical record JSON
Quality gates Evidence records Metric comparison and inspection
Batch grouping Shared UUID-based run-profile run_id across real Executions One mirror run per OCLP run
Parameters and scalar metrics Durable Execution/Evidence details where meaningful Experiment-comparison UI

MLflow is a mirror only: its copies never determine OCLP identity, validation, or lineage.

Start the local MLflow UI with:

uv run mlflow ui --backend-store-uri "sqlite:///$(pwd)/data/mlflow/mlflow.db"

OCLP records to look for

  • The feature table is an immutable CSV Artifact used for training.
  • Each fold child Execution accepts the prepared feature-table CSV and a temporal-fold JSON Artifact, then outputs a CatBoost model, validation predictions, and metrics.
  • The candidate evaluation publishes a detailed metrics Artifact and quality gate Evidence. The Evidence records the check, not every prediction value.
  • The final model, feature contract, evaluation report, training configuration, and feature table are named members of the release ArtifactSet.
  • The holdout scorer consumes the final model and feature-table member Artifacts directly, then produces prediction and metrics Artifacts plus a response-contract Evidence record.

Release-backed FastAPI inference

The example now includes a deliberately small local FastAPI service. It is started with an SDK-created release-manifest.json, not an arbitrary model path:

uv run bike-demand serve --release-manifest \
  data/runs/<run-id>/release/<release-key>/release-manifest.json

At application startup, oclp.load_release_manifest() verifies the manifest's exact ArtifactSet reference and resolves its locally available members. Each POST /predict request is persisted through @json_artifact as an external request Artifact. The predict_bike_demand Computation accepts that request Artifact and the entire ArtifactSetHandle as a model_release input. It explicitly materializes the release's model and feature-contract members, so the emitted Execution records a real ArtifactSet → Execution edge rather than an untracked release ID parameter. The SDK persists the JSON prediction response and emits the normal execution-started, artifacts-published, and terminal Event records. Prediction response validation is the same required Evidence gate used by the release inference smoke test.

This is intentionally correctness-first: every request reloads the verified model Artifact and records both payloads under data/inference/. It proves the release-to-serving contract without introducing production concerns such as sampling, redaction, asynchronous publication, caching, OpenTelemetry, or a metrics backend. Those are follow-on service concerns rather than requirements for the OCLP contract.