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 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:
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.