Skip to content

Layer 2 — Components

Callbacks, metric writers, validation. The Layer-2 modules are carrier-agnostic — they work for any adapter that drives the hooks below.


Callback Protocol

class Callback(Protocol):
    """Per-step / per-epoch / per-eval hooks.

    The adapter translates these calls into the backend's native
    callback API (Lightning: lit.Callback; Equinox: explicit hook
    points in the outer loop; Keras: keras.callbacks.Callback).
    """

    def on_train_begin(self, loop: TrainingLoop, state: CarryState) -> None: ...

    def on_step_end(
        self,
        loop: TrainingLoop,
        state: CarryState,
        metrics: dict[str, float],
    ) -> None: ...

    def on_epoch_end(
        self,
        loop: TrainingLoop,
        state: CarryState,
        metrics: dict[str, float],
    ) -> None: ...

    def on_eval_end(
        self,
        loop: TrainingLoop,
        state: CarryState,
        eval_metrics: dict[str, float],
    ) -> None: ...

    def on_train_end(self, loop: TrainingLoop, state: CarryState) -> None: ...

Five hooks. The merge between eqx_trainer's four hooks and the master plan's three: on_train_begin + on_train_end at the ends, on_step_end for every step, on_epoch_end for the (optional) epoch boundaries, on_eval_end after each eval pass. Each hook is optional — adapter dispatch uses getattr(cb, hook, None).

Note on @runtime_checkable. Unlike Loss and MetricWriter, Callback is not decorated with @runtime_checkable. The optional-hook contract above is incompatible with structural isinstance (which requires every named method to be present): a metric-logger callback that implements only on_step_end is valid at runtime but would be rejected by isinstance(cb, Callback). The Protocol therefore exists as the type-checker contract describing the full hook surface; runtime conformance is duck-typed via getattr. See ADR D9 in decisions.md.

Bundled callbacks

All are pipekit.Operators so they round-trip through YAML.

Checkpoint

class Checkpoint(Operator):
    """Save (model_op, optimizer_state) every N steps.

    With `registry` set, also stores to a ModelRegistry and records
    the model URI on the TrainingArtifact.

    Args:
        every_n_steps: Cadence.
        keep_last: Number of recent checkpoints to retain on disk.
        save_dir: Local directory or fsspec URI.
        registry: Optional pipekit-experiment.ModelRegistry; when set,
            the final checkpoint is also stored by content hash and
            the URI is wired back into TrainingArtifact.
    """
    every_n_steps: int = 1000
    keep_last: int = 3
    save_dir: str = "./ckpts"
    registry: Any | None = None     # pipekit_experiment.ModelRegistry

The adapter is responsible for the actual save. The Equinox adapter uses Orbax CheckpointManager.save_interval_steps; Lightning uses its built-in ModelCheckpoint. The registry field is the pipekit-train ↔ pipekit-experiment bridge — it fills TrainingArtifact.model_registry_uri and trained_model_hash.

EarlyStopping

class EarlyStopping(Operator):
    """Signal stop when a monitored metric hasn't improved for N evals.

    Args:
        metric: Metric name as it appears in the on_eval_end dict.
        patience: Number of consecutive evals without improvement
            before signalling stop.
        mode: "min" for losses, "max" for accuracies.
        min_delta: Minimum change to count as improvement.
    """
    metric: str
    patience: int = 10
    mode: Literal["min", "max"] = "min"
    min_delta: float = 0.0

The adapter checks the returned "should_stop" flag on each on_eval_end and exits cleanly if set. Lightning's native EarlyStopping callback is used directly when the Lightning adapter is in play.

LogToExperiment

The bridge to pipekit-experiment. Imports pipekit_experiment lazily — only when this callback is constructed.

class LogToExperiment(Operator):
    """Forward metrics / params / artifacts to a pipekit-experiment tracker.

    Args:
        tracker: Anything satisfying ExperimentTracker.
        run_name: Name passed to tracker.start_run.
        config: Extra config dict merged into the run start. The
            tracker also sees the TrainingLoop's get_config().
    """
    tracker: Any                    # pipekit_experiment.ExperimentTracker
    run_name: str
    config: dict[str, Any] | None = None

Per-step metrics flow through on_step_end → tracker.log_metrics. Per-eval metrics flow through on_eval_end → tracker.log_metrics (with a phase="eval" tag merged in). The final on_train_end calls tracker.end_run and the resulting tracker_run_id is stamped into TrainingArtifact.tracker_run_id.

This callback is the only point at which pipekit-train imports pipekit_experiment. Users without [experiment] extras can omit this callback and use a plain MetricWriter instead.


MetricWriter Protocol

@runtime_checkable
class MetricWriter(Protocol):
    """Where per-step metrics go when no tracker is attached.

    Implementations: JSONL, in-memory list, W&B, TensorBoard.
    The trainer calls write() per logged step.
    """
    def write(self, step: int, metrics: dict[str, float]) -> None: ...
    def close(self) -> None: ...

JSONLWriter

@dataclass
class JSONLWriter:
    """Append-only JSON Lines metric writer.

    Args:
        path: Output file path. Created if missing.
        flush_every: How many writes between fsync() calls.
    """
    path: str
    flush_every: int = 100

    def write(self, step: int, metrics: dict[str, float]) -> None: ...
    def close(self) -> None: ...

The default writer. No external dependency. Per-line schema: {"step": int, "metrics": dict[str, float], "timestamp": iso8601}. A W&B adapter is ten lines (see examples/integration.md).


ValidationStep

class ValidationStep(Operator):
    """Compute validation metrics on a held-out dataset.

    Used inside TrainingLoop on eval cadence, and standalone after
    training for the final-model evaluation report.

    Args:
        model_op: The (trained) model operator.
        dataset: The validation TrainingDataset.
        metrics: List of metric Operators (each takes (predicted,
            target) and returns a scalar). Composes with the rest of
            the pipekit ecosystem — pipekit-evaluate metrics, custom
            user metrics, …
    """
    model_op: Operator
    dataset: TrainingDataset
    metrics: tuple[Operator, ...]

    def __call__(self) -> dict[str, float]: ...

metrics is an Operator tuple, not callables, so the validation step round-trips through YAML and the user can swap in pipekit-evaluate metrics (when that package ships) without changing the TrainingLoop config.


HyperSweep + ParameterGrid (v0.2)

Hyperparameter sweep orchestration. Lives in pipekit_train.sweep and composes with the rest of Layer 3 (the trial's TrainingLoop is built per-call via a user-supplied template).

class ParameterGrid(Operator):
    """Cartesian product over named parameter axes.

    Iterating yields {axis_name: value} dicts. `len(grid)` is the
    product of axis lengths for non-empty grids, and 0 for the
    empty grid ({}) — the pragmatic "no axes ⇒ no trials"
    departure from the math convention that ∏ over zero axes = 1.
    """
    grid: dict[str, list[Any]]


@dataclass
class SweepResult:
    """One trial's result; sorted by HyperSweep.select_metric."""
    params: dict[str, Any]
    trained_model_op: Operator
    artifact: Any                 # TrainingArtifact | _LocalArtifact
    final_metric: float
    sweep_id: str
    trial_index: int


class HyperSweep(StatefulOperator):
    """Orchestrate N independent TrainingLoop runs.

    Args:
        loop_template: (params) -> TrainingLoop.
        search_space: ParameterGrid or any iterable of param dicts
            (so Optuna trial generators plug in v0.2.1).
        select_metric: Key inside artifact.backend_info["final_metrics"].
        select_mode: "min" / "max".
        n_trials: Optional cap.
        sweep_id: Stable identifier across trials (uuid by default).
        on_trial: Optional (SweepResult, SweepCarryState) -> None
            progress callback.

    Returns from `run()`: list[SweepResult] sorted by select_metric.
    """

Composition: HyperSweep is a StatefulOperator carrying SweepCarryState(completed, best_metric, best_trial_index), so coarse-then-fine sweeps compose into Sequential and the running best survives across stages.

Tracker provenance: sweep_id is the cross-trial tag. Users either wire a LogToExperiment callback inside their loop_template, including sweep_id in the per-run config, or inspect SweepResult.sweep_id after the fact. v0.2 leaves this explicit; an auto-wiring LogToExperiment(sweep_id=…) convenience is a v0.2.1 nice-to-have.

Scope:

Capability v0.2 v0.2.1 v0.3
ParameterGrid (Cartesian) ✅ — —
Generic iterable search space ✅ — —
Optuna / Ray Tune sampler adapters — ✅ —
Auto-LogToExperiment per trial — ✅ —
Parallel trials (multiprocessing) — — ✅