Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

pipekit-experiment

Report 12 · Experiment tracking, the model registry and reproducibility artifacts, as shipped and as still needed

Abstract

pipekit-experiment is where a pipekit model leaves the stack: it is logged to a tracker, stored in a registry, promoted to production and pinned in a reproducibility artifact. The scoping report asked for a thin adapter layer over MLflow, W&B, DVC, Hydra and Metaflow, with a content-addressed registry at its centre. Version 0.0.2 ships the registry, the two protocols, the run records, the training and inference artifacts, and adapters for Hydra, DVC and Metaflow. It does not ship a single tracker: there is no MLflow or W&B code, although their extras exist. Following one emulator from training to an inference artifact breaks in six places: load cannot rebuild a JAX model, a model stored without weights hashes on its class name alone, tags move without a trace, an artifact pins a tag instead of a hash, the training artifact loses its tracker run id, and nested pipelines do not survive the Hydra round trip. Every idea from the scoping report that has not shipped stays as a proposal with a gate.

Keywords:pipekit-experimentmodel registrycontent addressingexperiment trackingreproducibilityMLflowHydra

Summary

Decision. pipekit-experiment stays the thin boundary package the scoping report and its README describe: protocols, a content-addressed registry, JSON artifacts and one adapter per tool, with nothing from those tools imported by pipekit pipelines. The registry’s design stands. Five workstreams finish the package.

End state. A trained model is stored with its weights under a hash that identifies its architecture and weights, reloaded by hash or by tag in one call, promoted with a recorded tag move, and pinned by hash in an inference artifact. Its training run is logged to any tracker that satisfies the protocol, and its training artifact names the run, the data, the registry and enough of the loop to retrain.

Ownership rules

pipekit-experiment owns the records a run leaves behind and the registry models live in; the external tools own scheduling, dashboards and storage (Figure 1).

External tools at the top: Hydra, DVC and Metaflow have adapters, MLflow and W&B do not. pipekit-experiment holds the protocols, run records, the local and fsspec registries, the training and inference artifacts and three adapters; there is no tracker implementation. pipekit-train logs and stores into it; inference pipelines and pipekit-cycle load from it.

Figure 1:pipekit-experiment 0.0.2 between pipekit and the tools it adapts. Dashed chips are missing.

pipekit-experiment owns the ExperimentTracker and ModelRegistry protocols, the run records, the registries, the artifacts and the tool adapters. Its core depends only on pipekit; each tool arrives through its own extra. The registry stores weight bytes and never interprets them: each model operator (JaxModelOp, EquinoxModelOp) owns its format through serialize_weights and from_registry.

Neighbours (pipekit-train, the external tools, serving) follow the stack rules. One rule is this page’s: pipekit-train’s fallback _LocalArtifact (loop.py:174), which _build_artifact returns when pipekit-experiment is not installed (loop.py:427-433), is the only second artifact type, is never persisted, and is documented as such; removing it (making [experiment] a hard dependency of run()) is an open question.

What ships

Table 1 lists the public surface; the italic rows were planned and never built.

Table 1:pipekit-experiment 0.0.2’s public surface. Rows in italics are in the scoping report but not in the code.

GroupNamesModuleState
ProtocolsExperimentTracker, ModelRegistryprotocolsshipped; runtime_checkable, method names only
Run recordsRun, RunMetrics, RunArtifacts, RunStatusrunshipped; nothing creates them
RegistriesLocalModelRegistry(root), S3ModelRegistry(uri, storage_options)registryshipped
RegistriesMLflowModelRegistrynot built
ArtifactsTrainingArtifact, InferenceArtifactartifactsshipped; JSON save/load, reload_model(s)
AdaptersHydraConfigLoader, DVCDatasetVersioning, MetaflowStepAdapteradapters.hydra, .dvc, .metaflowshipped
AdaptersMLflowTracker, WandbTrackernot built

The running example: one emulator from training to an artifact

The stages follow the linear emulator of Report 11, a learned W∈R40×40W \in \mathbb{R}^{40 \times 40} that replaces Report 10’s transport operator MM, through everything pipekit-experiment does to a model after training. For readable outputs some stages use a two-parameter operator Scale(factor, offset), x↦factor⋅x+offsetx \mapsto \text{factor} \cdot x + \text{offset}, alongside it.

Stage 1 · Store the emulator under its content hash (pipekit-jax → pipekit-experiment)

TL;DR. Store the trained emulator and check that its address changes exactly when the model does.

Problem. The registry addresses a model by the hash (1),

h=SHA256(json(state)∥0x00∥w),h = \mathrm{SHA256}\left( \mathrm{json}(\text{state}) \Vert \texttt{0x00} \Vert w \right) ,

where state is the operator’s class and config and ww its serialised weights (empty if none). Two stores of the same model must give the same hh; changing the config or a weight must change it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Shapes: W (40, 40), b (40,) float32 → weights.bin 6,816 bytes
import pipekit_experiment as px
from pipekit_jax import JaxModelOp

reg = px.LocalModelRegistry("registry")                                # today
op = JaxModelOp(eqx.nn.Linear(40, 40, key=key))      # W and b      today
h = reg.store(op, name="lin40", weights=op.serialize_weights())       # today
# registry/<h>/operator.json  {"class": "JaxModelOp",
#                              "config": {"module_class": "Linear"}, …}
# registry/<h>/weights.bin    6,816 bytes
# registry/<h>/metadata.json  {"tags": {}}
# registry/_tags/lin40        <h>
# same op twice → same h; one config value or one weight changed → new h
reg.store(JaxModelOp(eqx.nn.Linear(40, 40, key=k1))) == \
    reg.store(JaxModelOp(eqx.nn.Linear(3, 7, key=k2)))   # → True: no weights

Storing a JaxModelOp with its weights, from a real run.

The address is exactly as the code defines it, and it behaves. Writes are atomic on the local backend (a .tmp file, then os.replace), and refs are checked as a single path segment, so reg.load("../x") raises. One case fails: without weights, a JaxModelOp’s state is just {"module_class": "Linear"}, so a 40 × 40 and a 3 × 7 layer share a hash, and the second store is merged into the first one’s entry.

Stage 2 · Load it back (pipekit-experiment → pipekit-cycle)

TL;DR. Reload the emulator by hash and by name, with its weights, and drop it into a cycle.

Problem. A round trip must be exact: max⁡∣Wloaded−W∣=0\max \lvert W_{\text{loaded}} - W \rvert = 0.

1
2
3
4
5
6
7
8
# Shapes: W (40, 40)
reg.load(h)
# → RuntimeError: from_state cannot reconstruct JaxModelOp: it is marked
#   forbid_in_yaml … Rebuild it in code instead.                      today
skeleton = JaxModelOp(eqx.nn.Linear(40, 40, use_bias=False, key=key0))
loaded = skeleton.from_registry(reg, "lin40")             # by tag     today
# → max |W_loaded − W| = 0.0;  a (20, 40) skeleton raises a shape error
emulator = pc.NeuralForward(loaded, dt=1.0)               # Report 10  today

The registry’s load path, and the skeleton path that works.

ModelRegistry.load(ref) rebuilds an operator with Operator.from_state and never touches weights.bin. That works for operators whose config is plain values (Scale), and fails for every model with weights, because JaxModelOp and EquinoxModelOp are forbid_in_yaml. The path that works is the model’s own: build a skeleton of the right shape, then from_registry(reg, ref) reads the weights into it. Workstream A1 makes load do this.

Stage 3 · Promote it (pipekit-experiment)

TL;DR. Mark the emulator as production, then promote a candidate, and see what the registry remembers.

Problem. Deployments resolve the tag production to a hash. A promotion must be deliberate, must not be silent, and must leave a record of what production meant before.

1
2
3
4
5
6
7
8
reg.tag(h3, "production")                                            # today
reg.tag(h4, "candidate")                                             # today
reg.tag(h4, "production")
# → FileExistsError: Tag 'production' already exists. Pass force=True …
reg.tag(h4, "production", force=True)       # moves the tag             today
reg.resolve_tag("production")               # → h4; h3 is mentioned nowhere
reg.store(op5, name="production")           # moves it again, no force   today
reg.list(tags={"v": "1"})                   # → []: the stored tag is the int 1

Tag-based promotion, from a real run.

Write-once tags are the code’s default, as the scoping report asked, and force=True is the explicit override. Two things undo that. store(..., name=...) moves a tag without force, so a script can repoint production by storing a model under that name. And no tag move is recorded: after a promotion, nothing says what production used to be. list(tags=...) compares with ==, so a string "1" does not match a stored integer 1. Workstream A3 fixes the first two.

Stage 4 · Pin it in an inference artifact (pipekit-experiment)

TL;DR. Freeze an inference pipeline that uses the emulator, so it can be rebuilt with exactly this model later.

Problem. An inference artifact maps each model in a pipeline to the hash it used: {operator path↦h}\{\text{operator path} \mapsto h\}. Rebuilding must load the same hh, whatever the tags say by then.

1
2
3
4
5
6
art = px.InferenceArtifact(pipeline_yaml=..., backend_info={}, deps_lock=...,
                           pinned_model_hashes={"steps.1": "production"})  # today
art.save("forecast.inference.json"); px.InferenceArtifact.load(...)   # equal
art.reload_models(reg)
# → {"steps.1": Scale(factor=3.0)}: "production" resolved to whatever it is now
# with a JaxModelOp pinned → RuntimeError (from_state, as in stage 2)

An InferenceArtifact, and what it accepts as a pin.

pinned_model_hashes takes any ref, and reload_models resolves tags, so an artifact pinned to "production" drifts the next time the tag moves: the opposite of a pin. It also inherits stage 2’s failure for models with weights. Workstream C2 accepts only hashes.

Stage 5 · Log the training run, and read its artifact (pipekit-train → pipekit-experiment)

TL;DR. Train the emulator with two trackers attached, and check what the training artifact records.

Problem. One training run should reach several trackers with identical metric streams, and its artifact should name the tracker run, the data, the stored model and the registry it lives in.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Shapes: 200 pairs; 50 steps
class MemTracker:          # satisfies px.ExperimentTracker: 4 methods     user
    def start_run(self, name, config): ...
    def log_metrics(self, run, metrics, step): ...
    def log_artifact(self, run, path, name): ...
    def end_run(self, run, status): ...

a, b = MemTracker(), MemTracker()
loop = pt.TrainingLoop(..., callbacks=(pt.LogToExperiment(a),
                                       pt.LogToExperiment(b),
                                       pt.Checkpoint(registry=reg)))      # today
trained, art = loop.run()                       # art: px.TrainingArtifact
# a and b: identical mse streams, 50 points, 3.28 → 0.0029; both "completed"
art.tracker_run_id        # → None
art.model_registry_uri    # → "registry://d120fe9b…": no registry root
reg.load_weights(art.trained_model_hash)        # → None: no weights stored
pk.loads(art.training_pipeline_yaml)
# → RuntimeError: from_state cannot reconstruct TrainingLoop … forbid_in_yaml

Two trackers on one loop, and the artifact the loop returns, from a real run.

Stacking trackers works as the scoping report promised: each LogToExperiment gets the same stream. But there is no tracker to stack; every one is user code. The artifact the loop fills has four problems, and only one is pipekit-experiment’s own:

Stage 6 · Hand the pipeline to orchestration tools (pipekit-experiment → Hydra, DVC, Metaflow)

TL;DR. Express the pipeline as Hydra YAML, version its data with DVC and run it as a Metaflow step.

Problem. Each adapter must round-trip what it is given: a pipeline to YAML and back, a file to a DVC hash, a pipeline to a runnable step.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pipekit_experiment.adapters.hydra import HydraConfigLoader as HL
HL.from_yaml(HL.to_yaml(Scale(2.0, 1.0)))(3.0)          # → 7.0          today
seq = pk.Sequential([Scale(2.0, 1.0), Scale(0.5, 0.0)])
HL.from_yaml(HL.to_yaml(seq))
# → InstantiationException: Sequential.operators[0] must be an Operator,
#   got DictConfig  (to_yaml wrote {class: Scale, config: …}, no _target_)

dvc = DVCDatasetVersioning(repo="dvcrepo")
dvc.add("data/train.csv"); dvc.hash("data/train.csv")   # → e5ebd4c0…    today
dvc.push("data/train.csv")      # → CalledProcessError: no remote

MetaflowStepAdapter.as_step(seq, name="scaled", inputs=["x"])
# → Metaflow: "Step scaled is missing a self.next() transition"
MetaflowStepAdapter.run(seq, self, name="scaled", inputs=["x"])  # inside a
# hand-written @step → 3.5                                              today

The three adapters that ship, from real runs.

Flat operators round-trip through Hydra; nested ones do not, because to_yaml writes pipekit’s debug config instead of _target_ entries, which is Report 9’s proposal A seen from this side (D1). The DVC adapter works and shells out to the dvc CLI; its hash reads the first md5: or hash: line of the pointer file, so a pointer with hash: md5 first would return the string "md5" (D3). as_step builds a step without self.next, which Metaflow refuses in any position but the end, while run inside a hand-written step works (D2).

The fsspec registry was checked the same way: S3ModelRegistry("memory://") gives the same hashes and layout as the local one, but list() on an empty registry raises FileNotFoundError, a file:// registry fails on its first store unless storage_options={"auto_mkdir": True}, and its writes are not atomic (A5). Real S3 was not tested.

The scoping report’s names, checked

Table 2 checks the stack names the scoping report’s examples use.

Table 2:The stack names the scoping report’s examples use, at the pinned revisions.

Name in the reportAt the pinned revisions
pe.S3ModelRegistry(bucket=..., prefix=...)S3ModelRegistry(uri, storage_options=None), over any fsspec URI
pe.adapters.mlflow.MLflowTracker, pe.adapters.wandb.WandbTracker, MLflowModelRegistrydo not exist
pe.adapters.hydra.HydraConfigLoader.from_hydra_cfgexists, with to_hydra_cfg, to_yaml and from_yaml
pe.adapters.metaflow.MetaflowStepAdapter.run(loop)run(op, namespace, *, name, inputs) and as_step(op, *, name, inputs, decorate=True)
pe.TrainingArtifact(...).save, .load, .rerun(), .reload_model()save, load and reload_model(registry) exist; rerun does not
pk.repro.Artifactdoes not exist
registry.store(op, name=..., tags=...), registry.load("methane_emulator_v3"), registry.tag(h, "production")exist; store also takes weights=; load fails for models with weights; tag raises unless force=True
registry.gc()does not exist
loop.to_yaml(), pt.Checkpoint(every_n_epochs=10), n_epochs=100TrainingLoop has no to_yaml and is forbid_in_yaml; Checkpoint and the loop count steps (Report 11)
pe as the import aliasthese reports use px, because pe is pipekit-evaluate elsewhere

Current-state audit

Of sixteen pieces checked, eight ship and work, six ship with defects, and two are missing (Table 3); Figure 2 counts the page’s calls by marker. The audit read references/geostack/pipekit at ef3c17f on 13 October 2026.

A grid with one row per stage of the running example and per workstream, and one square per stack call in that row's sketches, coloured by its marker. Of 21 calls, 17 run today, 3 are proposed by this report's workstreams and 1 is code the reader writes.

Figure 2:What this page’s sketches call: of 21 stack calls, 17 run today, 3 are proposed by this report’s workstreams and 1 is code the reader writes (how to read it).

Table 3:pipekit-experiment 0.0.2, piece by piece.

SymbolsDefined inUsed byVerdict
ExperimentTrackerprotocols.py:24LogToExperimentHealthy. Matches the plan; the runtime check sees names only
ModelRegistryprotocols.py:50Checkpoint, artifactsHealthy. Adds weights=, load_weights, force=
tracker implementationsMissing. None; the mlflow and wandb extras are empty
Run, RunMetrics, RunArtifactsrun.py:21, :60, :83Split ownership. Nothing creates them; no to_dataframe; attach takes a string
content hashregistry.py:46every storeSplit ownership. Exact with weights; class-name only without them
LocalModelRegistry(root)registry.py:245stages 1–5Healthy. Atomic writes, path-safe refs
S3ModelRegistry(uri, storage_options)registry.py:297stage 6Split ownership. Works on memory://; empty list raises; file:// needs auto_mkdir; writes not atomic
load(ref)registry.py (_resolve at :235)reload_model(s)Split ownership. from_state only; fails for models with weights
tag, resolve_tagregistry.py:200, :223stage 3Split ownership. Write-once with force; store(name=) bypasses it; no history
gc, delete, untag, MLflowModelRegistryMissing.
TrainingArtifactartifacts.py:48TrainingLoop.runHealthy as a record; no rerun (C1)
InferenceArtifactartifacts.py:88use case 9Split ownership. Pins accept tags
HydraConfigLoaderadapters/hydra.pyReport 9Healthy for flat operators; nested fails (D1)
DVCDatasetVersioningadapters/dvc.pyHealthy. CLI wrapper; line-scanned pointer hash
MetaflowStepAdapteradapters/metaflow.py:328, :359Healthy for run; as_step builds an invalid step
docsREADME.md, CHANGELOG.mdHealthy with two stale claims: “shipped (v0.0.1)”, and promotion “atomic” on every backend

Smaller findings:

Workstream A · The registry

Decision. The registry’s content hash and storage layout stand; what changes is that a stored model comes back whole, its address cannot collide, and every tag move is deliberate and recorded (Table 4).

Table 4:Workstream A’s moves.

SymbolDestinationReason
weight rebinding in load(ref, like=None)_ModelRegistryBase.loadwith like (a skeleton), call like.from_registry(self, ref); without it, keep today’s Operator.from_state path, which already rebuilds serializable operators such as Scale; only when that path cannot rebuild the stored model (it is forbid_in_yaml, or weights.bin exists and the class has no way to rebind it) raise a message that names like= instead of from_state’s
architecture in the hash for weightless storesJaxModelOp.state, EquinoxModelOp.state, or a store checkLinear(40, 40) and Linear(3, 7) share a hash without weights
store(name=...) honours write-once; _tags/_history.jsonlregistry.pya tag moves only with tag(..., force=True), and every move appends (time, tag, old, new)
gc(keep=...), delete(hash), untag(name)registry.pythe scoping report’s operational policies (§7.2)
auto_mkdir, empty list, write-then-rename where the backend supports itS3ModelRegistrystage 6’s three failures
MLflowModelRegistryadapters/mlflow.pynames map to MLflow model names, hashes to versions, as the scoping report proposed
1
2
3
4
5
6
7
8
9
10
11
# Shapes: W (40, 40)
# Today:
loaded = skeleton.from_registry(reg, "lin40")            # works       today
reg.load("lin40")                                        # RuntimeError
reg.store(op5, name="production")                        # silent move today

# After:
loaded = reg.load("lin40", like=skeleton)                # proposed (A1)
reg.store(op5, name="production")        # → FileExistsError  proposed (A3)
reg.tag(h5, "production", force=True)    # → appends to _history  proposed (A3)
reg.tag_history("production")            # → [(t₁, None, h3), (t₂, h3, h4), …]

Stages 2 and 3, before and after workstream A.

Workstream B · Trackers

Decision. Ship trackers, starting with one that needs no service, so that LogToExperiment has something to log to out of the box (Table 5).

Table 5:Workstream B’s moves.

SymbolDestinationReason
JSONLTracker(root)pipekit_experiment.trackersan offline, testable tracker: one directory per run with config.json, metrics.jsonl, artifacts.json; it creates Run and RunMetrics
MLflowTracker(tracking_uri, experiment_name)adapters/mlflow.pythe scoping report’s first adapter; the extra already exists
WandbTracker(project, entity=None)adapters/wandb.pythe second adapter; the extra already exists
_run_id(handle), used for tracker_run_idpipekit-train loop.py:414-419normalize each return type the ExperimentTracker protocol allows: a Run gives run.run_id; a mapping gives handle["run_id"] (or "id"); a str is the id itself; anything else gives None with a warning. Today the check is hasattr(run, "__getitem__") and the value repr(run), so a Run gives None and a mapping or string gives its repr
log_artifact on train endpipekit-train LogToExperimenttrackers never see the stored model or the artifact

Workstream C · Artifacts

Decision. An artifact pins by hash, names its registry, and keeps enough of its training loop to run it again (Table 6).

Table 6:Workstream C’s moves.

SymbolDestinationReason
TrainingArtifact.rerun(build_loop)artifacts.pyTrainingLoop is forbid_in_yaml, so rerun needs a builder: a script entry point or a Hydra config (D1) that rebuilds the loop, checked against dataset_hash
hash-only pinsInferenceArtifact.__post_init__, reload_modelsstage 4: a tag drifts
ModelRegistry.locationprotocols.py and both backends in registry.pya public URI for the registry: LocalModelRegistry returns self.root.resolve().as_uri() (file:///…), S3ModelRegistry its uri; both already compute it privately as _where() (:293, :366). LocalModelRegistry has root and no uri, so the address cannot be built from uri
model_registry_uri = f"{registry.location.rstrip('/')}/{hash}"pipekit-train callbacks.py:266registry://<hash> does not say which registry
training_artifact_hash in the model’s metadataCheckpoint → store(tags=...)links the content-addressed model back to its provenance (stage 1’s box)

Workstream D · Orchestration adapters

Decision. Each adapter round-trips what it is given; the three that ship get their gaps closed before more are added (Table 7).

Table 7:Workstream D’s moves.

SymbolDestinationReason
nested _target_ emission in to_yamladapters/hydra.py, with pipekit core’s get_configReport 9’s proposal A; nested pipelines fail today
self.next(...) in generated stepsMetaflowStepAdapter.as_step(..., next=...)Metaflow rejects the generated step
YAML parse of .dvc pointersDVCDatasetVersioning.hashthe line scan depends on key order

Workstream E · Run records

Decision. The run records are produced by the trackers (B1) and given the conveniences the scoping report specified (Table 8).

Table 8:Workstream E’s moves.

SymbolDestinationReason
RunMetrics.to_dataframe()run.pythe scoping report’s analysis path; pandas as an optional import
RunArtifacts.attach(name, source: str | Path | Operator)run.pya Path is stored as a URI; an Operator is stored in a registry and attached by hash

Testing gates

Each workstream merges only when its gates pass; the existing gates are checks the code passes today (Table 9).

Table 9:Gates for the proposals and for what ships.

GateWorkstreamPasses when
A1 Load with weightsA1reg.load("lin40", like=skeleton) returns weights equal to the stored ones to 0; reg.load(h_scale) still rebuilds Scale(2.0, 1.0) with no like, and InferenceArtifact.reload_models and TrainingArtifact.reload_model keep working for it; without like, loading a JaxModelOp raises an error that names like=
A2 No collisionA2weightless stores of Linear(40, 40) and Linear(3, 7) get different hashes
A3 Recorded promotionA3store(name="production") raises when the tag exists; after two forced moves, tag_history returns three entries in order
A4 Garbage collectionA4gc(keep={"production"}) removes every untagged hash and nothing a tag points to
A5 fsspec parityA5on memory:// and file:// with default options, empty list() returns [] and stage 1’s store and load give the local registry’s hashes
A6 MLflow registryA6against a local MLflow file store, store, tag and load(like=) round-trip stage 1’s emulator to 0
B1 File trackerB1two JSONLTrackers on stage 5’s loop write identical 50-line metric files, and isinstance(t, ExperimentTracker) holds
B2, B3 Service trackersB2, B3against a local MLflow file store (B2) and W&B offline mode (B3), stage 5’s 50 metric points are retrievable by run id
B4 Run idB4art.tracker_run_id equals the id for each return type: a Run (run.run_id), a mapping (["run_id"]), a str; an unknown handle gives None and one warning
C1 RetrainC1art.rerun(build_loop) on Report 11’s white-prior emulator gives weights within 1e-6 of the stored ones on CPU, and the same dataset_hash
C2 Hash pinsC2an InferenceArtifact with a tag in pinned_model_hashes raises at construction
C3 Registry URIC3art.model_registry_uri equals f"{reg.location}/{hash}" for both LocalModelRegistry (a file:// URI) and S3ModelRegistry("memory://…")
D1 Nested HydraD1from_yaml(to_yaml(seq))(x) == seq(x) for stage 6’s Sequential, and the configs are equal
D2 Metaflow stepD2a two-step flow built with as_step(..., next="end") runs and returns 3.5
D3 DVC pointerD3hash returns the md5 for pointers with either key order
E1 Run recordsE1RunMetrics.to_dataframe() has one row per (name, step) with B1’s values
R1 Content address (existing)—stage 1: same model twice gives the same hash; one changed config value or weight gives a new one. Passes today
R2 Skeleton reload (existing)—stage 2: from_registry reloads to 0 by hash and by tag. Passes today
R3 Stacked trackers (existing)—stage 5: two LogToExperiment callbacks receive identical streams. Passes today

Sequencing and risks

The registry and artifact fixes come first, because every model that goes through the registry today comes back without its weights or loses its pin; trackers come second, round trips last (Figure 3).

Phase 1: A1, A2 and A3 registry fixes and B4, C2 and C3 artifact fixes, then their gates. Phase 2: B1, B2 and B3 trackers, and A4, A5 and A6 registry additions, then their gates. Phase 3: C1 retraining, D1 to D3 adapter round trips, and E1 run records, then their gates.

Figure 3:Correctness first, then trackers, then round trips.

Open questions

  1. Should the registry hash include provenance? The code identifies the model by state and weights; the scoping report wanted training config and data too. This page keeps the code’s choice and links provenance through metadata (C). Is a second, provenance-inclusive hash needed for regulatory use?

  2. Where does a like skeleton come from at serving time? A server that loads production needs the architecture. Is it in the deployment config, in operator.json, or in a model card stored beside the weights?

  3. Does the stack need pipekit.repro.Artifact? Two JSON dataclasses cover training and inference. Is a core base class still wanted for other snapshots (use case 9’s regulatory artifact)?

  4. Do tags carry stages? The scoping report used candidate and production as plain tags. Should stages be a first-class field with allowed transitions, as MLflow has?

  5. Should pipekit-train keep _LocalArtifact? It lets run() work without pipekit-experiment, at the cost of a second artifact type that cannot be saved or reloaded. The alternative is to require the [experiment] extra for run() and return only TrainingArtifact.

  6. A preview with each model. The scoping report’s preview.json held example inputs and outputs for sanity checks. Is it worth adding, and does it belong in the registry or the model card?

Upcoming work

Table 10 lists the follow-ups.

Table 10:Follow-ups from checking the scoping report against the code.

Follow-upWhereWhy
Update the README’s version table and the “atomic” promotion claimpipekit-experiment README.mdit says “shipped (v0.0.1)” and promotion is atomic only locally
Drop or fill the mlflow and wandb extraspyproject.tomlthey install packages nothing uses
Add a real-DVC and a real-Metaflow testpipekit-experiment teststoday’s tests mock both tools
Add a JaxModelOp registry round-trip testpipekit-experiment testsno test reloads a model with weights
Compare tag values as strings in list(tags=...), or document typed equalityregistry.py{"v": "1"} does not match a stored 1

References

Sources