Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ The `plf.experiment` module provides powerful tools for managing your PPL databa

* **`get_ppls()`**: List all active pipeline IDs in the current Lab.
* **`get_ppl_status()`**: Returns a DataFrame summarizing the status, last run, and key metrics for all PPLs.
* **`compare_ppl_configs(pplid_a, pplid_b)`**: Compare two pipeline configs and return a structured diff of their `workflow` and `args` sections.
* **`get_ppl_history(pplid)`**: Return a DataFrame of run timestamps and session origins for a single pipeline.
* **`filter_ppls(query)`**: Filters PPLs based on configuration arguments (e.g., `filter_ppls("data_source=my_workflows.MyComponent")`).
* **`archive_ppl(ppls)`**: Archives a pipeline, moving its configurations and artifacts to an archived folder for safe storage.
* **`archive_ppl(ppls, reverse=True)`**: Unarchives a pipeline and returns it to the active environment.
Expand All @@ -219,6 +221,8 @@ plf run <pplid> # run a pipeline
plf archive <pplid> # archive a pipeline
plf delete <pplid> # delete from archive
plf stop # gracefully stop running pipeline(s)
plf compare <pplid1> <pplid2> # diff two pipeline configs
plf log <pplid> # show run history for a pipeline
plf export <pplid> --format yaml # export config (yaml or json)
plf init --config settings.json # initialize lab from config file
```
Expand Down
45 changes: 45 additions & 0 deletions src/plf/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from plf.experiment import (
PipeLine,
archive_ppl,
compare_ppl_configs,
delete_ppl,
get_ppl_history,
get_ppl_status,
get_ppls,
get_runnings,
Expand Down Expand Up @@ -83,6 +85,49 @@ def list_ppls(ctx):
click.echo(ppl)


@main.command()
@click.argument("pplid_a")
@click.argument("pplid_b")
@click.pass_context
def compare(ctx, pplid_a, pplid_b):
"""Compare configs of two pipelines and show differences."""
_load_lab(ctx.obj["settings"])
try:
result = compare_ppl_configs(pplid_a, pplid_b)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)

if result["identical"]:
click.echo(f"Configs for '{pplid_a}' and '{pplid_b}' are identical.")
return

click.echo(f"Differences between '{pplid_a}' and '{pplid_b}':")
for path, values in sorted(result["differences"].items()):
click.echo(f" {path}:")
click.echo(f" {pplid_a}: {values['left']!r}")
click.echo(f" {pplid_b}: {values['right']!r}")


@main.command(name="log")
@click.argument("pplid")
@click.pass_context
def show_log(ctx, pplid):
"""Show run history for a pipeline."""
_load_lab(ctx.obj["settings"])
try:
df = get_ppl_history(pplid)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)

if df.empty:
click.echo(f"No run history found for '{pplid}'.")
return

click.echo(df.to_string(index=False))


@main.command()
@click.argument("pplid")
@click.pass_context
Expand Down
153 changes: 150 additions & 3 deletions src/plf/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
This module have all function for initiating pipeline and training
"""

from typing import Optional, Dict, List
from typing import Any, Optional, Dict, List, Union
import json
import os
import shutil
Expand All @@ -15,15 +15,17 @@


__all__ = [
"PipeLine","TransferContext",
"PipeLine", "TransferContext",
"get_ppls",
"get_ppl_details",
"get_ppl_status",
"get_ppl_history",
"compare_ppl_configs",
"archive_ppl",
"delete_ppl",
"transfer_ppl",
"group_by_common_columns",
'filter_ppls'
"filter_ppls",
]


Expand Down Expand Up @@ -83,6 +85,151 @@ def get_ppl_status(ppls: Optional[list] = None) -> pd.DataFrame:
df = pd.DataFrame.from_dict(data, orient='index')
return df


def _extract_comparable_config(cnfg: Dict[str, Any]) -> Dict[str, Any]:
"""Return the parts of a config that define reproducibility (used for hashing).

Args:
cnfg: Full pipeline configuration dictionary loaded from disk.

Returns:
A dictionary containing only ``workflow`` and ``args`` keys.
"""
return {
"workflow": cnfg.get("workflow"),
"args": cnfg.get("args"),
}


def _config_diff(
left: Dict[str, Any],
right: Dict[str, Any],
path: str = "",
) -> Dict[str, Dict[str, Any]]:
"""Recursively compare two config dictionaries and collect differences.

Args:
left: Configuration from the first pipeline.
right: Configuration from the second pipeline.
path: Dot-separated key path used during recursion (internal).

Returns:
Mapping of dotted key paths to ``{"left": ..., "right": ...}`` entries.
"""
differences: Dict[str, Dict[str, Any]] = {}
all_keys = set(left.keys()) | set(right.keys())

for key in sorted(all_keys):
current_path = f"{path}.{key}" if path else key

if key not in left:
differences[current_path] = {"left": None, "right": right[key]}
continue
if key not in right:
differences[current_path] = {"left": left[key], "right": None}
continue

left_val = left[key]
right_val = right[key]

# Recurse into nested dicts (e.g. workflow.args, component configs).
if isinstance(left_val, dict) and isinstance(right_val, dict):
differences.update(_config_diff(left_val, right_val, current_path))
elif left_val != right_val:
differences[current_path] = {"left": left_val, "right": right_val}

return differences


def compare_ppl_configs(pplid_a: str, pplid_b: str) -> Dict[str, Any]:
"""Compare reproducibility-relevant settings of two pipelines.

Loads both pipeline configs and diffs their ``workflow`` and ``args``
sections — the same fields used when computing the config hash.

Args:
pplid_a: ID of the first pipeline.
pplid_b: ID of the second pipeline.

Returns:
A result dictionary with keys:

- ``identical`` (bool): True when no differences were found.
- ``pplid_a`` / ``pplid_b`` (str): The compared pipeline IDs.
- ``differences`` (dict): Dotted-path diffs, each with ``left`` and
``right`` values (``None`` when a key exists in only one config).

Raises:
ValueError: If either pipeline ID is not found in the active lab.
"""
pipeline_a = PipeLine()
if not pipeline_a.verify(pplid=pplid_a):
raise ValueError(f"Pipeline '{pplid_a}' not found")
pipeline_a.load(pplid=pplid_a)

pipeline_b = PipeLine()
if not pipeline_b.verify(pplid=pplid_b):
raise ValueError(f"Pipeline '{pplid_b}' not found")
pipeline_b.load(pplid=pplid_b)

comparable_a = _extract_comparable_config(pipeline_a.cnfg)
comparable_b = _extract_comparable_config(pipeline_b.cnfg)
differences = _config_diff(comparable_a, comparable_b)

return {
"identical": len(differences) == 0,
"pplid_a": pplid_a,
"pplid_b": pplid_b,
"differences": differences,
}


def get_ppl_history(pplid: str) -> pd.DataFrame:
"""Return the run history for a single pipeline.

Queries the ``runnings`` table in ``ppls.db`` and enriches each row with
session origin information from ``logs.db`` (via ``logid``).

Args:
pplid: Pipeline ID to look up.

Returns:
A DataFrame with columns ``runid``, ``pplid``, ``logid``, ``parity``,
``started_time``, and ``called_at``. Empty when the pipeline has never
been executed.

Raises:
ValueError: If the pipeline ID is not found in the active lab.
"""
data_path = os.path.abspath(get_shared_data()["data_path"])

pipeline = PipeLine()
if not pipeline.verify(pplid=pplid):
raise ValueError(f"Pipeline '{pplid}' not found")

ppls_db = Db(db_path=os.path.join(data_path, "ppls.db"))
rows = ppls_db.query(
"SELECT runid, pplid, logid, parity, started_time "
"FROM runnings WHERE pplid = ? ORDER BY started_time",
(pplid,),
)
ppls_db.close()

columns = ["runid", "pplid", "logid", "parity", "started_time"]
if not rows:
return pd.DataFrame(columns=columns + ["called_at"])

history = pd.DataFrame(rows, columns=columns)

# Join session metadata from logs.db (separate SQLite file).
logs_db = Db(db_path=os.path.join(data_path, "logs.db"))
log_rows = logs_db.query("SELECT logid, called_at FROM logs")
logs_db.close()
log_map = {logid: called_at for logid, called_at in log_rows}
history["called_at"] = history["logid"].map(log_map)

return history

def multi_run(ppls: Dict[str, int], last_epoch: int = 10, patience: int = 5) -> None:
"""
Train multiple pipelines up to a maximum number of epochs with optional patience.
Expand Down
62 changes: 62 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,68 @@ def test_status_empty(mock_status, mock_setup):
assert "No pipelines found." in result.output


@patch("plf.cli.lab_setup")
@patch("plf.cli.compare_ppl_configs")
def test_compare_identical(mock_compare, mock_setup):
mock_compare.return_value = {
"identical": True,
"pplid_a": "ppl_a",
"pplid_b": "ppl_b",
"differences": {},
}
result = invoke(["compare", "ppl_a", "ppl_b"])
assert result.exit_code == 0
assert "identical" in result.output


@patch("plf.cli.lab_setup")
@patch("plf.cli.compare_ppl_configs")
def test_compare_differences(mock_compare, mock_setup):
mock_compare.return_value = {
"identical": False,
"pplid_a": "ppl_a",
"pplid_b": "ppl_b",
"differences": {
"args.lr": {"left": 0.01, "right": 0.001},
},
}
result = invoke(["compare", "ppl_a", "ppl_b"])
assert result.exit_code == 0
assert "args.lr" in result.output


@patch("plf.cli.lab_setup")
@patch("plf.cli.compare_ppl_configs", side_effect=ValueError("Pipeline 'x' not found"))
def test_compare_not_found(mock_compare, mock_setup):
result = invoke(["compare", "ppl_a", "x"])
assert result.exit_code == 1
assert "not found" in result.output


@patch("plf.cli.lab_setup")
@patch("plf.cli.get_ppl_history")
def test_log_with_history(mock_history, mock_setup):
mock_history.return_value = pd.DataFrame({
"runid": [1],
"pplid": ["ppl_001"],
"logid": ["log0"],
"parity": [None],
"started_time": ["2025-01-01 00:00:00"],
"called_at": ["script:test.py"],
})
result = invoke(["log", "ppl_001"])
assert result.exit_code == 0
assert "ppl_001" in result.output


@patch("plf.cli.lab_setup")
@patch("plf.cli.get_ppl_history", return_value=pd.DataFrame())
def test_log_empty(mock_history, mock_setup):
result = invoke(["log", "ppl_001"])
assert result.exit_code == 0
assert "No run history found" in result.output


@patch("plf.cli.lab_setup")
@patch("plf.cli.PipeLine")
def test_run(mock_pipeline_cls, mock_setup):
Expand Down
Loading