Provenance and CMF
To reuse a computed result you need to know what produced it. TokSearch works that out for you. For every run it derives the shot source, the specification of every signal fetched, the sequence of operations, the compute backend, and the git commit of the script that ran, then hands all of it to a provenance backend. There is no metadata to write by hand.
The interface
toksearch.provenance defines the contract and nothing else. It has no
dependency on any metadata service; the CMF implementation lives in the
separate toksearch_cmf package.
RunContext is the derived description of a run. Its parts:
| Component | What it carries |
|---|---|
SourceSpec |
Where the shots came from: kind (shotlist, sql, recordset), count, a hash of the shot list, and for SQL sources the query and params |
OpSpec |
One per pipeline operation, in order: fetch, fetch_dataset, map, keep, where, align, write, each with its detail |
BackendSpec |
Which compute backend ran it, and its configuration |
CodeSpec |
The executing script, its argv, the git commit, and whether the tree was dirty |
signals |
The Signal.spec() of every fetched signal, keyed by record field, or by <dataset>.<name> for fetch_dataset |
RunContext.input_identity() hashes only the source, the signals, and the
device, deliberately excluding operations, backend, and code. Two runs that
read the same data have the same input identity even if they process it
differently.
Provenance is the ABC a backend implements:
| Hook | When |
|---|---|
on_compute_start(ctx) |
Before the backend runs |
on_compute_end(ctx, recordset) |
After compute returns |
output(*paths, **custom_properties) |
Declare artifacts toksearch did not write itself |
metrics(name, values) |
Record a named set of metrics |
finalize() |
Flush and close the record |
Three hooks are invoked by toksearch itself: on_compute_start,
on_compute_end, and the output recorded by RecordSet.to_parquet. These go
through safe_call, which converts any exception into a RuntimeWarning and
carries on: losing a provenance record is bad, losing a completed multi-hour
compute is worse. Set strict=True on the backend to make those propagate
instead, which suits CI but not production runs. metrics() and
finalize() get no such protection and strict does not reach them: you call
them yourself, so a failure inside one, such as a DVC or mlmd error in
CmfRun.finalize(), raises into your script like any other call, after
the compute has already finished. Put finalize() where an exception is
survivable, or wrap it, when the computed result matters more than the
record.
Recording a run
Pass a backend to any of the four compute_* methods. JsonProvenance needs
no external services, which makes it the right thing to try first:
import xarray as xr
from toksearch import Pipeline
from toksearch.provenance import JsonProvenance
def peaks(rec):
rec["peaks"] = xr.Dataset(
{"double": ("shot", [rec.shot * 2])},
coords={"shot": ("shot", [rec.shot])},
)
pipeline = Pipeline([180000, 180001])
pipeline.map(peaks)
pipeline.keep(["peaks"])
pipeline.write("out", field="peaks", fmt="netcdf")
run = JsonProvenance("demo", stage="explore", path="run.json")
results = pipeline.compute_serial(provenance=run)
run.metrics("coverage", {"requested": 2, "returned": len(results)})
run.finalize()
That writes out/180000.nc and out/180001.nc, and this run.json:
{
"context": {
"backend": {"config": {}, "kind": "SerialRecordSet"},
"code": {
"argv": ["demo.py"],
"commit": "b06208582106642adfc5666eef5c3c43628204d8",
"dirty": false,
"repo_root": "/path/to/demo",
"script": "demo.py"
},
"device": null,
"ops": [
{"op": "map",
"detail": {"func": {"module": "__main__", "name": "peaks",
"source_sha256": "30b447cd1662a427..."}}},
{"op": "keep", "detail": {"fields": ["peaks"]}},
{"op": "write",
"detail": {"directory": "/path/to/demo/out", "fields": ["peaks"],
"fmt": "netcdf", "func": null, "name": null,
"on_error": "skip", "path_field": "output_path",
"track": "directory"}}
],
"parent_run": null,
"signals": {},
"source": {"count": 2, "hash": "8964cb18ba55ec4e...", "kind": "shotlist",
"params": null, "query": null}
},
"input_identity": "ca52bcbedcaf50337f0c97ebf2533b38c481d7862f6647cb0ce57b195476bb69",
"metrics": {"coverage": {"requested": 2, "returned": 2}},
"outputs": [{"path": "/path/to/demo/out", "source": "pipeline_write"}],
"pipeline_name": "demo",
"run_id": "bd9348f4471b4f2fb14ca64aea5fcdf6",
"stage": "explore"
}
source_sha256 is the hash of the mapped function's source, so a change to
peaks produces a different record. commit and dirty come from the git
repository containing the script; outside a repository both are null.
Add a fetch and the signals block fills in with that signal's full
specification: class, module, expression, tree name, dims, and every
constructor field. That is what makes the input identity meaningful.
Chaining runs
Pipeline.compute copies the backend's run_id onto the returned
RecordSet. Building a new pipeline from that recordset with
Pipeline(previous_results) reads it back as RunContext.parent_run, so a
multi-stage analysis links itself without any bookkeeping on your part.
Getting data out
Pipeline.write
Write one file per record, in the worker that produced it. This is the recommended way to get data out of a pipeline: writing per shot in the workers is faster than concatenating on the driver, and more honest: concatenation is a transformation and deserves its own stage rather than hiding inside a writer.
Two forms. Declarative, which appends the operation immediately:
pipeline.write("out/peaks", field="ds", fmt="netcdf")
pipeline.write("out/peaks", fields=["ip", "betan"], fmt="netcdf") # xr.merge
Decorator, when the file's content needs computing:
A two-argument function takes (record, path), writes the file itself, and
returns the path it wrote.
| Argument | Meaning |
|---|---|
directory |
Output directory; created if absent |
field / fields |
One record field, or several merged with xarray.merge |
fmt |
netcdf, parquet, npy, npz, json; inferred from the object when omitted |
name |
(record) -> str basename without extension; defaults to the shot number |
track |
directory (one artifact for the whole directory) or file (one per shot) |
exist_ok |
Off by default. Two runs interleaving into one directory silently corrupt the directory's content hash, and flock is not cross-client on BeeGFS, so nothing else prevents it |
path_field |
Record field that receives the written path |
on_error |
skip (default) writes no file for a record that already failed, so the directory, and the provenance hash over it, covers exactly the shots that completed. write writes anyway |
Read the results back with xarray.open_mfdataset('out/peaks/*.nc') where
dask is installed. Without it, open per file:
import glob
import xarray as xr
ds = xr.concat(
[xr.open_dataset(f) for f in sorted(glob.glob("out/peaks/*.nc"))],
dim="shot",
)
Without netCDF4/h5netcdf, xarray writes NetCDF3 via scipy, which has no
int64, so integer coordinates read back as int32.
Driver-side collection
When the per-shot results are small enough to gather, RecordSet collapses
them directly:
results = pipeline.compute_multiprocessing(num_workers=16)
df = results.to_dataframe() # all scalar fields
df = results.to_dataframe(fields=["ip_max_ma"]) # a subset
results.to_parquet("peaks.parquet")
Recording to CMF
CMF (Common Metadata Framework) is a
git- and DVC-backed metadata store. toksearch_cmf provides CmfRun, a
Provenance implementation that records a run there. It owns every cmflib
and dvc dependency; TokSearch core knows about neither.
toksearch_cmf is part of the fdp-core metapackage, so an FDP environment
already has it.
from toksearch import MdsSignal, Pipeline
from toksearch_d3d import PtDataSignal
from toksearch_cmf import CmfRun
run = CmfRun("betan-ip-study", stage="assemble", work_dir=".")
pipeline = Pipeline(shots)
pipeline.fetch("ip", PtDataSignal("ip"))
pipeline.fetch(
"betan",
MdsSignal(r"\betan", "efit01", location="remote://atlas.gat.com"),
)
pipeline.map(peaks)
pipeline.keep(["peaks"])
pipeline.write("peaks", field="peaks", fmt="netcdf")
results = pipeline.compute_multiprocessing(num_workers=8, provenance=run)
run.metrics("coverage", {"requested": len(shots), "returned": len(results)})
run.finalize()
Nothing there hand-writes a cmflib.log_dataset call. TokSearch derives the
run description and CmfRun records it.
Prerequisites. cmflib records the executing script's commit and hands
output paths to DVC, so the script must run from inside a git repository that
has a remote and DVC initialised, with the script itself committed there.
CmfRun checks for the repository up front rather than failing after a long
compute.
Prefer python -m fdp run to fdp run. In any environment
carrying cmflib, graphviz arrives transitively (cmflib → dvc → pydot →
graphviz) and installs its own layout engine at bin/fdp. fdp 0.6.0
declared graphviz as a dependency so the installer's link order gives the FDP
CLI the file back (verified on pixi/rattler and micromamba 2.9.0). That still
leaves two ways to lose the collision: an fdp older than 0.6.0, or an
installer whose link order isn't guaranteed the way those two are. So plain
fdp run does work in a stock fdp-core environment, which is why the rest of
these docs use it. python -m fdp is simply the form that cannot be wrong:
A complete working example is
examples/betan_ip_peaks_cmf.py
in the toksearch_cmf repository. The technical claims in the prerequisite
passage above, the git+DVC requirement and the graphviz behavior with its two
residual exposures, are kept deliberately in sync with the same passage in
that repository's README. The surrounding wording differs where local context
demands it (only this page has to reconcile itself with the fdp run used
elsewhere in these docs), so compare the claims, not the prose.
API reference
toksearch.provenance.Provenance
Bases: ABC
Receives a RunContext and records it somewhere.
Implementations must not raise in normal operation: losing a provenance
record is bad, but losing a completed multi-hour compute is worse. Errors
are converted to warnings by safe_call unless strict is set.
on_compute_start(ctx)
abstractmethod
Called before the backend runs, with the derived RunContext.
on_compute_end(ctx, recordset)
abstractmethod
Called after compute returns, with the resulting RecordSet.
output(*paths, **custom_properties)
abstractmethod
Declare output artifacts that toksearch did not write itself.
metrics(name, values)
abstractmethod
Record a named set of metrics for this run.
finalize()
abstractmethod
Flush and close the record.
toksearch.provenance.RunContext
dataclass
Everything toksearch knows about one compute_* call.
This is the entire contract with a provenance backend. A backend receives a RunContext and nothing else; it never touches a Pipeline, a Signal, or a Record.
input_identity()
Hash of what data this run reads -- source plus signals.
Deliberately excludes ops, backend, and code: two runs that read the same data share an input artifact even if they then do different things with it. That shared artifact is what connects the lineage graph.
write_directories()
Output directories declared by Pipeline.write operations.
Read from the pipeline definition, not from the records. Ray and Spark
RecordSets are lazy -- SparkRecordSet.map returns an un-actioned
RDD and RayRecordSet.map returns unmaterialized ObjectRefs -- so
iterating one to discover written paths would force materialization as
a side effect of recording provenance. On Spark without caching, the
user's next action would then recompute the whole pipeline. The
directory is known statically, so nothing needs to be forced.
Only track="directory" writes are covered. track="file" asks
for per-shot artifacts, whose paths genuinely are per-record; a backend
wanting those must iterate, and should say so.
toksearch.provenance.SourceSpec
dataclass
Where the records came from.
toksearch.provenance.OpSpec
dataclass
One pipeline operation, described.
toksearch.provenance.BackendSpec
dataclass
Which compute backend ran, and how it was configured.
toksearch.provenance.CodeSpec
dataclass
Which code produced a run.
toksearch.provenance.JsonProvenance
toksearch.provenance.safe_call(provenance, hook, *args, **kwargs)
Invoke a provenance hook without letting it break the pipeline.
A None provenance is a no-op, so call sites need no branching.
Pipeline output operations
Pipeline.write is rendered in full on the Pipeline reference
page, and RecordSet.to_dataframe / RecordSet.to_parquet on the
RecordSet page. Their arguments are tabulated under
Getting data out above.