Skip to content

Commit 01c4aa5

Browse files
Merge pull request #158 from ashtarkb/mlflow-metrics
[caliper] solidify the metrics to mlflow
2 parents de20451 + 8543da1 commit 01c4aa5

10 files changed

Lines changed: 373 additions & 56 deletions

File tree

projects/caliper/cli/commands.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,76 @@ def kpi_csv_export(
695695
sys.exit(3)
696696

697697

698+
@click.command("kpis-to-mlflow")
699+
@click.option(
700+
"--input",
701+
"input_file",
702+
type=click.Path(path_type=Path),
703+
required=True,
704+
help="Input KPI JSON file (schema v2 hierarchical format)",
705+
)
706+
@click.option(
707+
"--artifacts-dir",
708+
"artifacts_dir",
709+
type=click.Path(path_type=Path, exists=True),
710+
required=True,
711+
help="Root of the artifact tree containing __test_labels__.yaml markers",
712+
)
713+
@click.option(
714+
"--status-file", type=click.Path(path_type=Path), help="YAML file to write operation status"
715+
)
716+
def kpis_to_mlflow_cmd(
717+
input_file: Path,
718+
artifacts_dir: Path,
719+
status_file: Path | None,
720+
) -> None:
721+
"""Convert kpis.json into per-run metrics.json + parameters.json for MLflow."""
722+
from projects.caliper.engine.kpi.kpis_to_mlflow import generate_metrics_from_kpis
723+
724+
status_data: dict = {"success": False}
725+
726+
try:
727+
result = generate_metrics_from_kpis(input_file, artifacts_dir)
728+
status = result.get("status", "unknown")
729+
if status == "success":
730+
status_data = {
731+
"success": True,
732+
"tests_processed": result.get("tests_processed", 0),
733+
"total_tests": result.get("total_tests", 0),
734+
}
735+
click.echo(
736+
f"Generated metrics.json for {result.get('tests_processed', 0)}/"
737+
f"{result.get('total_tests', 0)} test(s)"
738+
)
739+
elif status == "skipped":
740+
status_data = {"success": True, "skipped": True, "reason": result.get("reason", "")}
741+
click.echo(f"Skipped: {result.get('reason', '')}")
742+
else:
743+
status_data = {"success": False, "error": result.get("error", "unknown error")}
744+
click.echo(f"kpis-to-mlflow failed: {result.get('error', 'unknown')}", err=True)
745+
except Exception as e: # noqa: BLE001
746+
import traceback
747+
748+
full_traceback = traceback.format_exc()
749+
status_data = {"success": False, "error": str(e), "traceback": full_traceback}
750+
click.echo(f"kpis-to-mlflow failed: {e}", err=True)
751+
click.echo(f"Full traceback:\n{full_traceback}", err=True)
752+
753+
if not status_file:
754+
sys.exit(3)
755+
finally:
756+
if status_file:
757+
try:
758+
with open(status_file, "w", encoding="utf-8") as f:
759+
yaml.dump(status_data, f, default_flow_style=False)
760+
except Exception as status_err:
761+
click.echo(f"Failed to write status file {status_file}: {status_err}", err=True)
762+
sys.exit(4)
763+
764+
if not status_data.get("success", False):
765+
sys.exit(3)
766+
767+
698768
@click.command("import")
699769
@click.option("--snapshot", type=click.Path(path_type=Path), required=True)
700770
@click.pass_context
@@ -1063,7 +1133,6 @@ def artifacts_export(
10631133
status_yaml_path: Path | None,
10641134
upload_workers: int,
10651135
) -> None:
1066-
10671136
# Load config from file if provided, CLI args override
10681137
config = {}
10691138
if mlflow_config_path:

projects/caliper/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
kpi_generate,
1919
kpi_import,
2020
kpi_s3_import,
21+
kpis_to_mlflow_cmd,
2122
list_reports_cmd,
2223
parse_cmd,
2324
visualize_cmd,
@@ -212,6 +213,7 @@ def run_cli() -> None:
212213
kpi_group.add_command(kpi_import)
213214
kpi_group.add_command(analyse_kpis_cmd)
214215
kpi_group.add_command(kpi_s3_import)
216+
kpi_group.add_command(kpis_to_mlflow_cmd)
215217

216218
# Register s3-export command under kpi group
217219
kpi_group.add_command(s3_export_cmd)

projects/caliper/engine/file_export/mlflow_backend.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,48 @@ def _load_json_file(path: Path) -> dict[str, Any]:
461461
return {}
462462

463463

464+
def _log_2d_metrics(metrics_2d: dict[str, Any]) -> None:
465+
"""Log 2D metrics as stepped MLflow metrics.
466+
467+
Each key maps to a list of ``{"x": ..., "y": ...}`` dicts (already
468+
sorted by x in ``metrics_from_kpis``). Each data point is logged
469+
with ``step=int(x)`` so MLflow renders the curve.
470+
471+
Raises:
472+
ValueError: If a data point has a non-integer x value (MLflow
473+
steps must be integers) or if x/y are not numeric.
474+
"""
475+
import mlflow
476+
477+
for metric_name, data_points in metrics_2d.items():
478+
if not isinstance(data_points, list):
479+
continue
480+
for i, pt in enumerate(data_points):
481+
if not isinstance(pt, dict):
482+
raise ValueError(
483+
f"2D metric {metric_name!r}: data_points[{i}] is {type(pt).__name__}, "
484+
f"expected dict with 'x' and 'y' keys"
485+
)
486+
x = pt.get("x")
487+
y = pt.get("y")
488+
if (
489+
not isinstance(x, int | float)
490+
or isinstance(x, bool)
491+
or not isinstance(y, int | float)
492+
or isinstance(y, bool)
493+
):
494+
raise ValueError(
495+
f"2D metric {metric_name!r}: data_points[{i}] has non-numeric "
496+
f"x={x!r} or y={y!r}"
497+
)
498+
if x != int(x):
499+
raise ValueError(
500+
f"2D metric {metric_name!r}: data_points[{i}] has non-integer "
501+
f"step x={x!r} (MLflow steps must be integers)"
502+
)
503+
mlflow.log_metric(str(metric_name), float(y), step=int(x))
504+
505+
464506
def _log_metrics_and_params_from_tree(artifact_root: Path) -> None:
465507
"""Find metrics.json/parameters.json under __test_labels__.yaml-marked dirs and log them."""
466508
import mlflow
@@ -473,7 +515,9 @@ def _log_metrics_and_params_from_tree(artifact_root: Path) -> None:
473515
mf = run_dir / "metrics.json"
474516
if mf.is_file():
475517
for k, v in _load_json_file(mf).items():
476-
if isinstance(v, int | float) and not isinstance(v, bool):
518+
if isinstance(v, list):
519+
_log_2d_metrics({k: v})
520+
elif isinstance(v, int | float) and not isinstance(v, bool):
477521
mlflow.log_metric(str(k), float(v))
478522

479523
pf = run_dir / "parameters.json"
@@ -593,7 +637,9 @@ def _run(uri: str | None) -> tuple[str, dict[str, Any] | None]:
593637
mf = run_dir / metrics_file
594638
if mf.is_file():
595639
for k, v in _load_json_file(mf).items():
596-
if isinstance(v, int | float) and not isinstance(v, bool):
640+
if isinstance(v, list):
641+
_log_2d_metrics({k: v})
642+
elif isinstance(v, int | float) and not isinstance(v, bool):
597643
mlflow.log_metric(str(k), float(v))
598644

599645
pf = run_dir / parameters_file
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Generic kpis.json -> metrics.json + parameters.json conversion.
2+
3+
Reads a hierarchical kpis.json (schema v2) and writes per-test-run
4+
metrics.json and parameters.json files into the matching artifact tree
5+
directories. The MLflow export backend picks these up automatically via
6+
``_log_metrics_and_params_from_tree``.
7+
8+
This replaces project-specific metrics.json generation (e.g. in
9+
mcp_gateway parsers) with a single generic caliper mechanism that works
10+
for every project producing a kpis.json.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import logging
17+
from pathlib import Path
18+
from typing import Any
19+
20+
logger = logging.getLogger(__name__)
21+
22+
METRICS_FILE = "metrics.json"
23+
PARAMETERS_FILE = "parameters.json"
24+
TEST_LABELS_MARKER = "__test_labels__.yaml"
25+
26+
27+
def _build_run_dir_index(artifact_tree: Path) -> dict[str, Path]:
28+
"""Map run directory names to their paths using __test_labels__.yaml markers."""
29+
index: dict[str, Path] = {}
30+
for marker in sorted(artifact_tree.rglob(TEST_LABELS_MARKER)):
31+
if marker.is_file():
32+
run_dir = marker.parent
33+
try:
34+
rel = run_dir.relative_to(artifact_tree)
35+
except ValueError:
36+
rel = Path(run_dir.name)
37+
index[str(rel)] = run_dir
38+
index[run_dir.name] = run_dir
39+
return index
40+
41+
42+
def _is_scalar(value: Any) -> bool:
43+
"""Check if a KPI value is a scalar number (not 2D data)."""
44+
return isinstance(value, int | float) and not isinstance(value, bool)
45+
46+
47+
def _extract_2d_points(value: Any) -> list[dict[str, float]] | None:
48+
"""Extract sorted (x, y) data points from a 2D KPI value.
49+
50+
Returns a list of ``{"x": ..., "y": ...}`` dicts sorted by x,
51+
or ``None`` if the value is not a valid 2D structure.
52+
"""
53+
if not isinstance(value, dict):
54+
return None
55+
data_points = value.get("data_points")
56+
if not isinstance(data_points, list) or not data_points:
57+
return None
58+
points = []
59+
for pt in data_points:
60+
if isinstance(pt, dict) and _is_scalar(pt.get("x")) and _is_scalar(pt.get("y")):
61+
points.append({"x": float(pt["x"]), "y": float(pt["y"])})
62+
if not points:
63+
return None
64+
points.sort(key=lambda p: p["x"])
65+
return points
66+
67+
68+
def _write_json(path: Path, data: dict[str, Any]) -> None:
69+
path.parent.mkdir(parents=True, exist_ok=True)
70+
with path.open("w", encoding="utf-8") as f:
71+
json.dump(data, f, indent=2, sort_keys=True)
72+
f.write("\n")
73+
74+
75+
def generate_metrics_from_kpis(
76+
kpis_json_path: Path,
77+
artifact_tree: Path,
78+
) -> dict[str, Any]:
79+
"""Convert kpis.json into per-run metrics.json and parameters.json files.
80+
81+
For each test entry in kpis.json, finds the matching directory under
82+
``artifact_tree`` (via ``__test_labels__.yaml`` markers) and writes:
83+
84+
- ``metrics.json``: ``{kpi_id: value}`` for all scalar KPIs
85+
- ``parameters.json``: test-level labels as string key-value pairs
86+
87+
Args:
88+
kpis_json_path: Path to the kpis.json file (schema v2).
89+
artifact_tree: Root of the caliper artifact tree containing
90+
test run directories with ``__test_labels__.yaml`` markers.
91+
92+
Returns:
93+
Status dict with counts and any warnings.
94+
"""
95+
if not kpis_json_path.is_file():
96+
raise FileNotFoundError(f"kpis.json not found: {kpis_json_path}")
97+
98+
with kpis_json_path.open(encoding="utf-8") as f:
99+
data = json.load(f)
100+
101+
if not isinstance(data, dict) or data.get("schema_version") != "2":
102+
return {"status": "skipped", "reason": "Not a schema v2 kpis.json"}
103+
104+
tests = data.get("tests", [])
105+
if not tests:
106+
return {"status": "skipped", "reason": "No tests in kpis.json"}
107+
108+
run_dir_index = _build_run_dir_index(artifact_tree)
109+
if not run_dir_index:
110+
logger.warning("No test run directories found under %s", artifact_tree)
111+
return {"status": "skipped", "reason": "No run directories with __test_labels__.yaml found"}
112+
113+
written = 0
114+
warnings: list[str] = []
115+
116+
for test_entry in tests:
117+
run_id = test_entry.get("run_id", "")
118+
test_base_path = (
119+
test_entry.get("metadata", {}).get("source", {}).get("test_base_path", run_id)
120+
)
121+
122+
run_dir = run_dir_index.get(test_base_path) or run_dir_index.get(run_id)
123+
if run_dir is None:
124+
warnings.append(f"No matching directory for run_id={run_id!r}")
125+
continue
126+
127+
kpis = test_entry.get("kpis", [])
128+
metrics: dict[str, Any] = {}
129+
for kpi in kpis:
130+
kpi_id = kpi.get("id", "")
131+
if not kpi_id:
132+
continue
133+
value = kpi.get("value")
134+
is_2d = kpi.get("is_2d", False)
135+
if is_2d:
136+
points = _extract_2d_points(value)
137+
if points:
138+
metrics[kpi_id] = points
139+
elif _is_scalar(value):
140+
metrics[kpi_id] = value
141+
142+
if metrics:
143+
_write_json(run_dir / METRICS_FILE, metrics)
144+
145+
labels = test_entry.get("labels", {})
146+
if labels:
147+
params = {str(k): ("" if v is None else str(v)) for k, v in labels.items()}
148+
_write_json(run_dir / PARAMETERS_FILE, params)
149+
150+
written += 1
151+
152+
result: dict[str, Any] = {
153+
"status": "success",
154+
"tests_processed": written,
155+
"total_tests": len(tests),
156+
}
157+
if warnings:
158+
result["warnings"] = warnings
159+
for w in warnings:
160+
logger.warning("kpis-to-metrics: %s", w)
161+
162+
logger.info(
163+
"Generated metrics.json for %d/%d test(s) from %s",
164+
written,
165+
len(tests),
166+
kpis_json_path.name,
167+
)
168+
return result

projects/caliper/orchestration/cli_builder.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,30 @@ def build_ai_eval_export_command(
299299
return cmd
300300

301301

302+
def build_kpis_to_mlflow_command(
303+
tree_root: Path,
304+
status_file: Path,
305+
input_file: Path,
306+
) -> list[str]:
307+
"""Build CLI command for caliper kpi kpis-to-mlflow.
308+
309+
Args:
310+
tree_root: Root of the artifact tree with __test_labels__.yaml markers
311+
status_file: Where to write status YAML
312+
input_file: Input kpis.json file (schema v2)
313+
314+
Returns:
315+
List of command arguments for subprocess
316+
"""
317+
cmd = _CALIPER_BASE_CMD + ["kpi", "kpis-to-mlflow"]
318+
319+
cmd.extend(["--input", str(input_file)])
320+
cmd.extend(["--artifacts-dir", str(tree_root)])
321+
cmd.extend(["--status-file", str(status_file)])
322+
323+
return cmd
324+
325+
302326
def build_s3_import_command(
303327
config: CaliperOrchestrationPostprocessConfig,
304328
status_file: Path,

0 commit comments

Comments
 (0)