rpx_benchmark.examples.summarize_metrics

Calculate Φ and JEDI from CSV, JSON or JSONL raw metric records.

Records need scene/scene_id, phase, and explicitly selected metric columns. Optional model and task columns separate independent runs. Three canonical phases are required in each run; incomplete scenes are reported and dropped by the existing complete-case analysis. Aggregate paper tables cannot recover the paired observations needed for Φ.

  1"""Calculate Φ and JEDI from CSV, JSON or JSONL raw metric records.
  2
  3Records need scene/scene_id, phase, and explicitly selected metric columns.
  4Optional model and task columns separate independent runs. Three canonical
  5phases are required in each run; incomplete scenes are reported and dropped
  6by the existing complete-case analysis. Aggregate paper tables cannot recover
  7the paired observations needed for Φ.
  8"""
  9
 10from __future__ import annotations
 11
 12import argparse
 13import csv
 14import hashlib
 15import json
 16import math
 17from dataclasses import asdict
 18from pathlib import Path
 19from typing import Any
 20
 21from ..exceptions import MetricError
 22from ..metrics.specs import get_spec
 23from ..phi_jedi_summary import CANONICAL_PHASE_ORDER, summarize_phi_jedi
 24
 25
 26def read_records(path: Path) -> list[dict[str, Any]]:
 27    """Read a list of records; runner JSON may wrap them in ``per_sample``."""
 28    records: Any
 29    if path.suffix.lower() == ".csv":
 30        with path.open(newline="", encoding="utf-8") as handle:
 31            records = list(csv.DictReader(handle))
 32    elif path.suffix.lower() in {".jsonl", ".ndjson"}:
 33        records = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
 34    else:
 35        records = json.loads(path.read_text())
 36        if isinstance(records, dict):
 37            wrapper = records
 38            records = wrapper.get("per_sample")
 39            if isinstance(records, list):
 40                # Runner JSON keeps model and task once, at the top level.
 41                inherited = {k: wrapper[k] for k in ("model", "task") if isinstance(wrapper.get(k), str)}
 42                records = [
 43                    {**inherited, **r} if isinstance(r, dict) else r for r in records
 44                ]
 45    if (
 46        not isinstance(records, list)
 47        or not records
 48        or not all(isinstance(r, dict) for r in records)
 49    ):
 50        raise MetricError("Input must contain a nonempty list of raw metric records")
 51    return records
 52
 53
 54def calculate(records: list[dict[str, Any]], metric_keys: list[str]) -> dict[str, Any]:
 55    """Validate raw values and summarize each (model, task) independently."""
 56    if not metric_keys or len(set(metric_keys)) != len(metric_keys):
 57        raise MetricError("Choose at least one metric, with no duplicates")
 58    specs = {key: asdict(get_spec(key)) for key in metric_keys}
 59    groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
 60    aliases = {"0": "clutter", "1": "interaction", "2": "clean"}
 61    for index, record in enumerate(records, 1):
 62        row = dict(record)
 63        scene = row.get("scene") or row.get("scene_id")
 64        if scene is None or not str(scene).strip():
 65            raise MetricError(f"Row {index}: scene/scene_id is required")
 66        phase = aliases.get(str(row.get("phase")), str(row.get("phase")).lower())
 67        if phase not in CANONICAL_PHASE_ORDER:
 68            raise MetricError(f"Row {index}: phase must be clutter, interaction, clean or 0/1/2")
 69        row.update(scene=str(scene), phase=phase)
 70        for key in metric_keys:
 71            raw = row.get(key)
 72            if isinstance(raw, bool):
 73                raise MetricError(f"Row {index}: {key} cannot be a boolean")
 74            if raw is None:
 75                raise MetricError(f"Row {index}: {key} must be a finite number")
 76            try:
 77                value = float(raw)
 78            except (TypeError, ValueError) as exc:
 79                raise MetricError(f"Row {index}: {key} must be a finite number") from exc
 80            if not math.isfinite(value):
 81                raise MetricError(f"Row {index}: {key} must be a finite number")
 82            row[key] = value
 83        group = (str(row.get("model") or "custom"), str(row.get("task") or "unspecified"))
 84        groups.setdefault(group, []).append(row)
 85    results = []
 86    for (model, task), rows in groups.items():
 87        phases = {row["phase"] for row in rows}
 88        if phases != set(CANONICAL_PHASE_ORDER):
 89            raise MetricError(
 90                f"{model}/{task}: all three phases are required; found {sorted(phases)}"
 91            )
 92        summary = summarize_phi_jedi(rows, metric_keys=metric_keys).to_dict()
 93        jedi = summary["jedi"]
 94        j_min = min(jedi["per_phase"].values()) if jedi else None
 95        results.append(
 96            {"model": model, "task": task, "input_rows": len(rows), "j_min": j_min, **summary}
 97        )
 98    return {
 99        "protocol": "three_phase_complete_case",
100        "metric_specs": specs,
101        "phase_order": list(CANONICAL_PHASE_ORDER),
102        "results": results,
103    }
104
105
106def main() -> None:
107    parser = argparse.ArgumentParser(description=__doc__)
108    parser.add_argument("--input", type=Path, required=True)
109    parser.add_argument("--metrics", nargs="+", required=True)
110    parser.add_argument("--output", type=Path, required=True)
111    args = parser.parse_args()
112    try:
113        result = calculate(read_records(args.input), args.metrics)
114    except MetricError as exc:
115        parser.exit(2, f"rpx: cannot compute Φ/JEDI: {exc}\n")
116    result["input_sha256"] = hashlib.sha256(args.input.read_bytes()).hexdigest()
117    args.output.parent.mkdir(parents=True, exist_ok=True)
118    args.output.write_text(json.dumps(result, indent=2, allow_nan=False) + "\n")
119    for row in result["results"]:
120        phi = (row.get("phi") or {}).get("phi_conservative")
121        per_phase = (row.get("jedi") or {}).get("per_phase") or {}
122        phases = "  ".join(f"{name} {value:.2f}" for name, value in per_phase.items())
123        phi_text = f"{phi:.2f}" if isinstance(phi, (int, float)) else "n/a"
124        print(
125            f"{row['model']} / {row['task']}: Φ {phi_text}   Jmin {row['j_min']:.2f}"
126            f"   ({row['n_eff']} scenes; J per phase: {phases})"
127        )
128    print(f"Full report: {args.output}")
129
130
131if __name__ == "__main__":
132    main()
def read_records(path: pathlib.Path) -> list[dict[str, typing.Any]]:
27def read_records(path: Path) -> list[dict[str, Any]]:
28    """Read a list of records; runner JSON may wrap them in ``per_sample``."""
29    records: Any
30    if path.suffix.lower() == ".csv":
31        with path.open(newline="", encoding="utf-8") as handle:
32            records = list(csv.DictReader(handle))
33    elif path.suffix.lower() in {".jsonl", ".ndjson"}:
34        records = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
35    else:
36        records = json.loads(path.read_text())
37        if isinstance(records, dict):
38            wrapper = records
39            records = wrapper.get("per_sample")
40            if isinstance(records, list):
41                # Runner JSON keeps model and task once, at the top level.
42                inherited = {k: wrapper[k] for k in ("model", "task") if isinstance(wrapper.get(k), str)}
43                records = [
44                    {**inherited, **r} if isinstance(r, dict) else r for r in records
45                ]
46    if (
47        not isinstance(records, list)
48        or not records
49        or not all(isinstance(r, dict) for r in records)
50    ):
51        raise MetricError("Input must contain a nonempty list of raw metric records")
52    return records

Read a list of records; runner JSON may wrap them in per_sample.

def calculate( records: list[dict[str, typing.Any]], metric_keys: list[str]) -> dict[str, typing.Any]:
 55def calculate(records: list[dict[str, Any]], metric_keys: list[str]) -> dict[str, Any]:
 56    """Validate raw values and summarize each (model, task) independently."""
 57    if not metric_keys or len(set(metric_keys)) != len(metric_keys):
 58        raise MetricError("Choose at least one metric, with no duplicates")
 59    specs = {key: asdict(get_spec(key)) for key in metric_keys}
 60    groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
 61    aliases = {"0": "clutter", "1": "interaction", "2": "clean"}
 62    for index, record in enumerate(records, 1):
 63        row = dict(record)
 64        scene = row.get("scene") or row.get("scene_id")
 65        if scene is None or not str(scene).strip():
 66            raise MetricError(f"Row {index}: scene/scene_id is required")
 67        phase = aliases.get(str(row.get("phase")), str(row.get("phase")).lower())
 68        if phase not in CANONICAL_PHASE_ORDER:
 69            raise MetricError(f"Row {index}: phase must be clutter, interaction, clean or 0/1/2")
 70        row.update(scene=str(scene), phase=phase)
 71        for key in metric_keys:
 72            raw = row.get(key)
 73            if isinstance(raw, bool):
 74                raise MetricError(f"Row {index}: {key} cannot be a boolean")
 75            if raw is None:
 76                raise MetricError(f"Row {index}: {key} must be a finite number")
 77            try:
 78                value = float(raw)
 79            except (TypeError, ValueError) as exc:
 80                raise MetricError(f"Row {index}: {key} must be a finite number") from exc
 81            if not math.isfinite(value):
 82                raise MetricError(f"Row {index}: {key} must be a finite number")
 83            row[key] = value
 84        group = (str(row.get("model") or "custom"), str(row.get("task") or "unspecified"))
 85        groups.setdefault(group, []).append(row)
 86    results = []
 87    for (model, task), rows in groups.items():
 88        phases = {row["phase"] for row in rows}
 89        if phases != set(CANONICAL_PHASE_ORDER):
 90            raise MetricError(
 91                f"{model}/{task}: all three phases are required; found {sorted(phases)}"
 92            )
 93        summary = summarize_phi_jedi(rows, metric_keys=metric_keys).to_dict()
 94        jedi = summary["jedi"]
 95        j_min = min(jedi["per_phase"].values()) if jedi else None
 96        results.append(
 97            {"model": model, "task": task, "input_rows": len(rows), "j_min": j_min, **summary}
 98        )
 99    return {
100        "protocol": "three_phase_complete_case",
101        "metric_specs": specs,
102        "phase_order": list(CANONICAL_PHASE_ORDER),
103        "results": results,
104    }

Validate raw values and summarize each (model, task) independently.

def main() -> None:
107def main() -> None:
108    parser = argparse.ArgumentParser(description=__doc__)
109    parser.add_argument("--input", type=Path, required=True)
110    parser.add_argument("--metrics", nargs="+", required=True)
111    parser.add_argument("--output", type=Path, required=True)
112    args = parser.parse_args()
113    try:
114        result = calculate(read_records(args.input), args.metrics)
115    except MetricError as exc:
116        parser.exit(2, f"rpx: cannot compute Φ/JEDI: {exc}\n")
117    result["input_sha256"] = hashlib.sha256(args.input.read_bytes()).hexdigest()
118    args.output.parent.mkdir(parents=True, exist_ok=True)
119    args.output.write_text(json.dumps(result, indent=2, allow_nan=False) + "\n")
120    for row in result["results"]:
121        phi = (row.get("phi") or {}).get("phi_conservative")
122        per_phase = (row.get("jedi") or {}).get("per_phase") or {}
123        phases = "  ".join(f"{name} {value:.2f}" for name, value in per_phase.items())
124        phi_text = f"{phi:.2f}" if isinstance(phi, (int, float)) else "n/a"
125        print(
126            f"{row['model']} / {row['task']}: Φ {phi_text}   Jmin {row['j_min']:.2f}"
127            f"   ({row['n_eff']} scenes; J per phase: {phases})"
128        )
129    print(f"Full report: {args.output}")