This is an automated email from the ASF dual-hosted git repository.

rombert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-whiteboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 6a6b3095 feat(skill-evals): include estimated cost when comparing runs
6a6b3095 is described below

commit 6a6b30955acb946b6651bb84a8510a81899841fb
Author: Robert Munteanu <[email protected]>
AuthorDate: Thu Apr 23 16:24:50 2026 +0200

    feat(skill-evals): include estimated cost when comparing runs
---
 skill-evals/.gitignore                             |   1 +
 skill-evals/README.md                              |   8 +-
 skill-evals/src/skill_evals/compare_eval_render.py | 392 ++++++++++
 skill-evals/src/skill_evals/compare_eval_report.py | 836 +++++++++++++++++++++
 4 files changed, 1235 insertions(+), 2 deletions(-)

diff --git a/skill-evals/.gitignore b/skill-evals/.gitignore
index 48996696..eb0408c8 100644
--- a/skill-evals/.gitignore
+++ b/skill-evals/.gitignore
@@ -4,3 +4,4 @@
 
 .env
 .sops.yaml
+/.cache/
diff --git a/skill-evals/README.md b/skill-evals/README.md
index 94a0bd2d..d1c733c3 100644
--- a/skill-evals/README.md
+++ b/skill-evals/README.md
@@ -79,7 +79,10 @@ uv run inspect view
 ## Comparing Eval Runs
 
 Use the reusable comparison utility to compare two Inspect log files by 
configuration, score,
-execution time, and token usage.
+execution time, token usage, and computed cost.
+
+Pricing data is cached locally after the first fetch in 
`.cache/models.dev-api.json` and reused
+across subsequent comparison runs.
 
 ```bash
 uv run skill-evals-compare LOG_A LOG_B
@@ -120,7 +123,8 @@ uv run skill-evals-compare LOG_A LOG_B --headline-score 
parent_pom_update
 uv run skill-evals-compare LOG_A LOG_B --fail-on-unexpected-diff
 ```
 
-`--samples` adds dataset-entry comparison aggregated across all epochs for 
each sample id.
+`--samples` adds dataset-entry comparison aggregated across all epochs for 
each sample id,
+including average token, cost, and execution time estimates per epoch.
 
 If a run contains multiple scores, use `--headline-score` to choose which 
scorer or score record
 is used for the headline score and stderr rows.
diff --git a/skill-evals/src/skill_evals/compare_eval_render.py 
b/skill-evals/src/skill_evals/compare_eval_render.py
new file mode 100644
index 00000000..25570417
--- /dev/null
+++ b/skill-evals/src/skill_evals/compare_eval_render.py
@@ -0,0 +1,392 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any
+
+from .compare_eval_report import JsonValue
+
+
+def _headline_score_from_scores(
+    scores: dict[str, JsonValue], headline_score_key: str | None
+) -> float | None:
+    if not isinstance(scores, dict) or not scores:
+        return None
+    if not headline_score_key:
+        return None
+    score_value = scores.get(headline_score_key)
+    if isinstance(score_value, (int, float)):
+        return float(score_value)
+    return None
+
+
+def _format_float(value: float | None, digits: int = 4) -> str:
+    if value is None:
+        return "n/a"
+    return f"{value:.{digits}f}"
+
+
+def _format_currency(value: float | None, digits: int = 4) -> str:
+    if value is None:
+        return "n/a"
+    return f"${value:.{digits}f}"
+
+
+def _format_delta(
+    left: float | int | None, right: float | int | None, digits: int = 4
+) -> str:
+    if left is None or right is None:
+        return "n/a"
+    delta = right - left
+    if isinstance(left, int) and isinstance(right, int):
+        return f"{delta:+d}"
+    return f"{delta:+.{digits}f}"
+
+
+def _percent_delta(left: float | int | None, right: float | int | None) -> 
float | None:
+    if left is None or right is None:
+        return None
+    baseline = float(left)
+    if baseline == 0:
+        return None
+    return round(((float(right) - baseline) / baseline) * 100, 4)
+
+
+def _format_percent_delta(
+    left: float | int | None, right: float | int | None, digits: int = 1
+) -> str:
+    percent = _percent_delta(left, right)
+    if percent is None:
+        return "n/a"
+    return f"{percent:+.{digits}f}%"
+
+
+def _sample_entry_metric_row(sample: dict[str, Any]) -> list[str]:
+    headline_score_key = sample.get("headline_score_key")
+    left = sample["left"]
+    right = sample["right"]
+    left_score = _headline_score_from_scores(left.get("mean_score", {}), 
headline_score_key)
+    right_score = _headline_score_from_scores(
+        right.get("mean_score", {}), headline_score_key
+    )
+    left_avg_tokens = left.get("avg_tokens_per_epoch")
+    right_avg_tokens = right.get("avg_tokens_per_epoch")
+    left_avg_cost = left.get("avg_cost_per_epoch")
+    right_avg_cost = right.get("avg_cost_per_epoch")
+    left_avg_total_time = left.get("avg_total_time")
+    right_avg_total_time = right.get("avg_total_time")
+    return [
+        sample["sample_id"],
+        _format_float(left_score),
+        _format_float(right_score),
+        _format_delta(left_score, right_score),
+        _format_percent_delta(left_score, right_score),
+        _format_float(left_avg_tokens, 1),
+        _format_float(right_avg_tokens, 1),
+        _format_delta(left_avg_tokens, right_avg_tokens, 1),
+        _format_percent_delta(left_avg_tokens, right_avg_tokens),
+        _format_currency(left_avg_cost),
+        _format_currency(right_avg_cost),
+        _format_delta(left_avg_cost, right_avg_cost),
+        _format_percent_delta(left_avg_cost, right_avg_cost),
+        _format_float(left_avg_total_time, 3),
+        _format_float(right_avg_total_time, 3),
+        _format_delta(left_avg_total_time, right_avg_total_time, 3),
+        _format_percent_delta(left_avg_total_time, right_avg_total_time),
+    ]
+
+
+def _json_default(value: Any) -> Any:
+    if isinstance(value, datetime):
+        return value.isoformat()
+    raise TypeError(f"Object of type {type(value).__name__} is not JSON 
serializable")
+
+
+def render_json(report: dict[str, Any]) -> str:
+    return json.dumps(report, indent=2, sort_keys=True, default=_json_default)
+
+
+def _render_table(headers: list[str], rows: list[list[str]]) -> str:
+    widths = [len(header) for header in headers]
+    for row in rows:
+        for index, value in enumerate(row):
+            widths[index] = max(widths[index], len(value))
+
+    def render_row(row: list[str]) -> str:
+        return "  ".join(value.ljust(widths[index]) for index, value in 
enumerate(row))
+
+    lines = [render_row(headers), render_row(["-" * width for width in 
widths])]
+    lines.extend(render_row(row) for row in rows)
+    return "\n".join(lines)
+
+
+def render_text(report: dict[str, Any]) -> str:
+    left = report["left"]
+    right = report["right"]
+    summary = report["summary"]
+    headline_score_key = report.get("headline_score_key")
+    lines = ["Eval Comparison", ""]
+    lines.append("Runs")
+    lines.append(
+        _render_table(
+            ["Side", "Task", "Model", "Log"],
+            [
+                ["left", str(left["task"]), str(left["model"]), 
str(left["path"])],
+                ["right", str(right["task"]), str(right["model"]), 
str(right["path"])],
+            ],
+        )
+    )
+    lines.append("")
+    lines.append("Expected differences: " + (", 
".join(report["expected_diff"]) or "none"))
+    actual = [diff["coordinate"] for diff in report["actual_differences"]]
+    lines.append("Actual differences: " + (", ".join(actual) or "none"))
+    lines.append("")
+    lines.append("Outcome summary")
+    if isinstance(headline_score_key, str) and headline_score_key:
+        lines.append(f"Selected scorer key: {headline_score_key}")
+    lines.append(
+        _render_table(
+            ["Metric", "left", "right", "delta", "% delta"],
+            [
+                [
+                    "headline score",
+                    _format_float(summary["score"]["left"]),
+                    _format_float(summary["score"]["right"]),
+                    _format_delta(summary["score"]["left"], 
summary["score"]["right"]),
+                    _format_percent_delta(
+                        summary["score"]["left"], summary["score"]["right"]
+                    ),
+                ],
+                [
+                    "stderr",
+                    _format_float(summary["stderr"]["left"]),
+                    _format_float(summary["stderr"]["right"]),
+                    _format_delta(
+                        summary["stderr"]["left"], summary["stderr"]["right"]
+                    ),
+                    _format_percent_delta(
+                        summary["stderr"]["left"], summary["stderr"]["right"]
+                    ),
+                ],
+                [
+                    "duration_seconds",
+                    _format_float(summary["duration_seconds"]["left"], 3),
+                    _format_float(summary["duration_seconds"]["right"], 3),
+                    _format_delta(
+                        summary["duration_seconds"]["left"],
+                        summary["duration_seconds"]["right"],
+                        3,
+                    ),
+                    _format_percent_delta(
+                        summary["duration_seconds"]["left"],
+                        summary["duration_seconds"]["right"],
+                    ),
+                ],
+                [
+                    "total_tokens",
+                    str(
+                        summary["total_tokens"]["left"]
+                        if summary["total_tokens"]["left"] is not None
+                        else "n/a"
+                    ),
+                    str(
+                        summary["total_tokens"]["right"]
+                        if summary["total_tokens"]["right"] is not None
+                        else "n/a"
+                    ),
+                    _format_delta(
+                        summary["total_tokens"]["left"], 
summary["total_tokens"]["right"]
+                    ),
+                    _format_percent_delta(
+                        summary["total_tokens"]["left"], 
summary["total_tokens"]["right"]
+                    ),
+                ],
+                [
+                    "total_cost_usd",
+                    _format_currency(summary["total_cost"]["left"]),
+                    _format_currency(summary["total_cost"]["right"]),
+                    _format_delta(summary["total_cost"]["left"], 
summary["total_cost"]["right"]),
+                    _format_percent_delta(
+                        summary["total_cost"]["left"], 
summary["total_cost"]["right"]
+                    ),
+                ],
+            ],
+        )
+    )
+    lines.append("")
+    lines.append("Coordinate differences")
+    if report["actual_differences"]:
+        lines.append(
+            _render_table(
+                ["Coordinate", "left", "right"],
+                [
+                    [
+                        diff["coordinate"],
+                        json.dumps(diff["left"], sort_keys=True),
+                        json.dumps(diff["right"], sort_keys=True),
+                    ]
+                    for diff in report["actual_differences"]
+                ],
+            )
+        )
+    else:
+        lines.append("none")
+
+    sample_differences = report["sample_entry_differences"]
+    if sample_differences is not None:
+        lines.append("")
+        lines.append("Sample differences")
+        lines.append(
+            f"entries with differences: 
{sample_differences['entries_with_differences']} / 
{sample_differences['total_entries']}"
+        )
+        differing_rows = [
+            _sample_entry_metric_row(
+                {**sample, "headline_score_key": 
report.get("headline_score_key")}
+            )
+            for sample in sample_differences["comparisons"]
+            if sample["different_fields"]
+        ]
+        if differing_rows:
+            lines.append(
+                _render_table(
+                    [
+                        "sample_id",
+                        "left_score",
+                        "right_score",
+                        "score_delta",
+                        "score_%_delta",
+                        "left_avg_tokens",
+                        "right_avg_tokens",
+                        "tokens_delta",
+                        "tokens_%_delta",
+                        "left_avg_cost",
+                        "right_avg_cost",
+                        "cost_delta",
+                        "cost_%_delta",
+                        "left_avg_total_time",
+                        "right_avg_total_time",
+                        "time_delta",
+                        "time_%_delta",
+                    ],
+                    differing_rows,
+                )
+            )
+        else:
+            lines.append("none")
+
+    lines.append("")
+    lines.append("Warnings")
+    if report["warnings"]:
+        lines.extend(f"- {warning}" for warning in report["warnings"])
+    else:
+        lines.append("- none")
+    return "\n".join(lines)
+
+
+def _markdown_escape(value: Any) -> str:
+    text = json.dumps(value, sort_keys=True) if isinstance(value, (dict, 
list)) else str(value)
+    return text.replace("|", "\\|")
+
+
+def render_markdown(report: dict[str, Any]) -> str:
+    left = report["left"]
+    right = report["right"]
+    summary = report["summary"]
+    headline_score_key = report.get("headline_score_key")
+    lines = [
+        "# Eval Comparison",
+        "",
+        "## Runs",
+        "",
+        "| Side | Task | Model | Log |",
+        "|---|---|---|---|",
+    ]
+    lines.append(
+        f"| left | `{_markdown_escape(left['task'])}` | 
`{_markdown_escape(left['model'])}` | `{_markdown_escape(left['path'])}` |"
+    )
+    lines.append(
+        f"| right | `{_markdown_escape(right['task'])}` | 
`{_markdown_escape(right['model'])}` | `{_markdown_escape(right['path'])}` |"
+    )
+    lines.extend(["", "## Expected Differences", ""])
+    if report["expected_diff"]:
+        lines.extend(f"- `{coordinate}`" for coordinate in 
report["expected_diff"])
+    else:
+        lines.append("- None")
+
+    lines.extend(
+        [
+            "",
+            "## Outcome Summary",
+            "",
+        ]
+    )
+    if isinstance(headline_score_key, str) and headline_score_key:
+        lines.extend([f"Selected scorer key: `{headline_score_key}`", ""])
+    lines.extend(
+        [
+            "| Metric | left | right | delta | % delta |",
+            "|---|---:|---:|---:|---:|",
+        ]
+    )
+    lines.append(
+        f"| Headline score | {_format_float(summary['score']['left'])} | 
{_format_float(summary['score']['right'])} | 
{_format_delta(summary['score']['left'], summary['score']['right'])} | 
{_format_percent_delta(summary['score']['left'], summary['score']['right'])} |"
+    )
+    lines.append(
+        f"| Stderr | {_format_float(summary['stderr']['left'])} | 
{_format_float(summary['stderr']['right'])} | 
{_format_delta(summary['stderr']['left'], summary['stderr']['right'])} | 
{_format_percent_delta(summary['stderr']['left'], summary['stderr']['right'])} 
|"
+    )
+    lines.append(
+        f"| Duration (s) | {_format_float(summary['duration_seconds']['left'], 
3)} | {_format_float(summary['duration_seconds']['right'], 3)} | 
{_format_delta(summary['duration_seconds']['left'], 
summary['duration_seconds']['right'], 3)} | 
{_format_percent_delta(summary['duration_seconds']['left'], 
summary['duration_seconds']['right'])} |"
+    )
+    left_tokens = summary["total_tokens"]["left"]
+    right_tokens = summary["total_tokens"]["right"]
+    lines.append(
+        f"| Total tokens | {left_tokens if left_tokens is not None else 'n/a'} 
| {right_tokens if right_tokens is not None else 'n/a'} | 
{_format_delta(left_tokens, right_tokens)} | 
{_format_percent_delta(left_tokens, right_tokens)} |"
+    )
+    lines.append(
+        f"| Total cost (USD) | 
{_format_currency(summary['total_cost']['left'])} | 
{_format_currency(summary['total_cost']['right'])} | 
{_format_delta(summary['total_cost']['left'], summary['total_cost']['right'])} 
| {_format_percent_delta(summary['total_cost']['left'], 
summary['total_cost']['right'])} |"
+    )
+
+    lines.extend(["", "## Coordinate Differences", ""])
+    if report["actual_differences"]:
+        lines.append("| Coordinate | left | right |")
+        lines.append("|---|---|---|")
+        for diff in report["actual_differences"]:
+            lines.append(
+                f"| `{_markdown_escape(diff['coordinate'])}` | 
`{_markdown_escape(diff['left'])}` | `{_markdown_escape(diff['right'])}` |"
+            )
+    else:
+        lines.append("None")
+
+    sample_differences = report["sample_entry_differences"]
+    if sample_differences is not None:
+        lines.extend(["", "## Sample Differences", ""])
+        lines.append(
+            f"Entries with differences: 
`{sample_differences['entries_with_differences']} / 
{sample_differences['total_entries']}`"
+        )
+        differing = [
+            sample
+            for sample in sample_differences["comparisons"]
+            if sample["different_fields"]
+        ]
+        if differing:
+            lines.extend(
+                [
+                    "",
+                    "| Sample ID | Left score | Right score | Score delta | 
Score % delta | Left avg tokens | Right avg tokens | Tokens delta | Tokens % 
delta | Left avg cost | Right avg cost | Cost delta | Cost % delta | Left avg 
total time | Right avg total time | Time delta | Time % delta |",
+                    
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
+                ]
+            )
+            for sample in differing:
+                row = _sample_entry_metric_row(
+                    {**sample, "headline_score_key": 
report.get("headline_score_key")}
+                )
+                lines.append(
+                    f"| `{_markdown_escape(row[0])}` | {row[1]} | {row[2]} | 
{row[3]} | {row[4]} | {row[5]} | {row[6]} | {row[7]} | {row[8]} | {row[9]} | 
{row[10]} | {row[11]} | {row[12]} | {row[13]} | {row[14]} | {row[15]} | 
{row[16]} |"
+                )
+
+    lines.extend(["", "## Warnings", ""])
+    if report["warnings"]:
+        lines.extend(f"- {warning}" for warning in report["warnings"])
+    else:
+        lines.append("- None")
+    return "\n".join(lines)
diff --git a/skill-evals/src/skill_evals/compare_eval_report.py 
b/skill-evals/src/skill_evals/compare_eval_report.py
new file mode 100644
index 00000000..1729807e
--- /dev/null
+++ b/skill-evals/src/skill_evals/compare_eval_report.py
@@ -0,0 +1,836 @@
+from __future__ import annotations
+
+import json
+import math
+from functools import lru_cache
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from urllib.error import URLError
+from urllib.request import Request, urlopen
+from typing import Any
+
+from inspect_ai.log import read_eval_log, read_eval_log_sample_summaries
+
+
+JsonValue = dict[str, Any] | list[Any] | str | int | float | bool | None
+
+MODELS_DEV_API_URL = "https://models.dev/api.json";
+MODELS_DEV_PROVIDER_ALIASES = {"bedrock": "amazon-bedrock"}
+MODELS_DEV_CACHE_PATH = Path(__file__).resolve().parents[2] / ".cache" / 
"models.dev-api.json"
+
+
+EXPECTED_DIFF_GROUPS: dict[str, set[str]] = {
+    "skill_enabled": {"skill_enabled", "task_args", "metadata", "task"},
+    "model": {"model", "model_generate_config"},
+    "task": {"task"},
+    "task_args": {"task_args"},
+    "metadata": {"metadata"},
+    "config": {"config"},
+    "model_generate_config": {"model_generate_config"},
+}
+
+
+@dataclass(frozen=True)
+class CoordinateDiff:
+    coordinate: str
+    left: JsonValue
+    right: JsonValue
+
+
+@dataclass(frozen=True)
+class CostComputation:
+    total_cost: float | None
+    warnings: list[str]
+
+
+@dataclass(frozen=True)
+class PricingData:
+    model_costs: dict[str, dict[str, float]]
+    source: str
+
+
+def _expand_expected_diff(expected_diff: set[str]) -> set[str]:
+    expanded: set[str] = set()
+    for item in expected_diff:
+        expanded.update(EXPECTED_DIFF_GROUPS.get(item, {item}))
+    return expanded
+
+
+def _parse_time(value: str | None) -> datetime | None:
+    if not value:
+        return None
+    return datetime.fromisoformat(value)
+
+
+def _round_number(value: Any, digits: int = 6) -> Any:
+    if isinstance(value, bool) or not isinstance(value, (int, float)):
+        return value
+    if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
+        return value
+    return round(value, digits)
+
+
+def _normalize(value: Any) -> JsonValue:
+    if hasattr(value, "model_dump"):
+        return _normalize(value.model_dump())
+    if isinstance(value, dict):
+        return {
+            str(key): _normalize(val)
+            for key, val in sorted(value.items(), key=lambda item: 
str(item[0]))
+        }
+    if isinstance(value, list):
+        return [_normalize(item) for item in value]
+    if isinstance(value, tuple):
+        return [_normalize(item) for item in value]
+    return _round_number(value)
+
+
+def _deep_equal(left: Any, right: Any) -> bool:
+    return _normalize(left) == _normalize(right)
+
+
+def _diff_value(left: Any, right: Any) -> tuple[JsonValue, JsonValue] | None:
+    left_normalized = _normalize(left)
+    right_normalized = _normalize(right)
+    if left_normalized == right_normalized:
+        return None
+
+    if isinstance(left_normalized, dict) and isinstance(right_normalized, 
dict):
+        left_diff: dict[str, JsonValue] = {}
+        right_diff: dict[str, JsonValue] = {}
+        for key in sorted(set(left_normalized) | set(right_normalized)):
+            child_diff = _diff_value(left_normalized.get(key), 
right_normalized.get(key))
+            if child_diff is None:
+                continue
+            left_child, right_child = child_diff
+            left_diff[key] = left_child
+            right_diff[key] = right_child
+        if not left_diff and not right_diff:
+            return None
+        return left_diff, right_diff
+
+    return left_normalized, right_normalized
+
+
+def _safe_getattr(obj: Any, name: str, default: Any = None) -> Any:
+    return getattr(obj, name, default) if obj is not None else default
+
+
+def _compute_duration_seconds(stats: Any) -> float | None:
+    started = _parse_time(_safe_getattr(stats, "started_at"))
+    completed = _parse_time(_safe_getattr(stats, "completed_at"))
+    if started is None or completed is None:
+        return None
+    return round((completed - started).total_seconds(), 3)
+
+
+def _extract_model_usage(stats: Any) -> dict[str, JsonValue]:
+    usage = _safe_getattr(stats, "model_usage", {}) or {}
+    normalized = _normalize(usage)
+    assert isinstance(normalized, dict)
+    return normalized
+
+
+@lru_cache(maxsize=1)
+def _load_models_dev_pricing() -> PricingData:
+    payload, source = _load_models_dev_payload()
+    return PricingData(model_costs=_flatten_models_dev_costs(payload), 
source=source)
+
+
+def _load_models_dev_payload() -> tuple[dict[str, Any], str]:
+    if MODELS_DEV_CACHE_PATH.exists():
+        with MODELS_DEV_CACHE_PATH.open() as cache_file:
+            payload = json.load(cache_file)
+        if not isinstance(payload, dict):
+            raise ValueError("cached models.dev API response is not a JSON 
object")
+        return payload, f"cache:{MODELS_DEV_CACHE_PATH}"
+
+    request = Request(
+        MODELS_DEV_API_URL,
+        headers={"User-Agent": "skill-evals-compare/0.1"},
+    )
+    with urlopen(request) as response:
+        payload = json.load(response)
+
+    if not isinstance(payload, dict):
+        raise ValueError("models.dev API response is not a JSON object")
+
+    MODELS_DEV_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
+    with MODELS_DEV_CACHE_PATH.open("w") as cache_file:
+        json.dump(payload, cache_file, sort_keys=True)
+
+    return payload, f"remote:{MODELS_DEV_API_URL}"
+
+
+def _flatten_models_dev_costs(payload: dict[str, Any]) -> dict[str, dict[str, 
float]]:
+    
+    model_costs: dict[str, dict[str, float]] = {}
+    for provider_id, provider_data in payload.items():
+        if not isinstance(provider_data, dict):
+            continue
+        models = provider_data.get("models", {})
+        if not isinstance(models, dict):
+            continue
+        for model_id, model_data in models.items():
+            if not isinstance(model_data, dict):
+                continue
+            cost_data = model_data.get("cost", {})
+            if not isinstance(cost_data, dict):
+                continue
+            normalized_cost: dict[str, float] = {}
+            for key, value in cost_data.items():
+                if isinstance(value, (int, float)):
+                    normalized_cost[str(key)] = float(value)
+            if not normalized_cost:
+                continue
+            qualified_model_id = str(model_data.get("id") or model_id)
+            model_costs[qualified_model_id] = normalized_cost
+            provider_qualified_model_id = f"{provider_id}/{qualified_model_id}"
+            model_costs.setdefault(provider_qualified_model_id, 
normalized_cost)
+
+    return model_costs
+
+
+def _lookup_model_cost(
+    model_costs: dict[str, dict[str, float]], model_id: str
+) -> dict[str, float] | None:
+    exact_match = model_costs.get(model_id)
+    if exact_match is not None:
+        return exact_match
+
+    provider, separator, provider_model_id = model_id.partition("/")
+    if not separator:
+        return None
+
+    aliased_provider = MODELS_DEV_PROVIDER_ALIASES.get(provider)
+    if aliased_provider is None:
+        return None
+
+    return model_costs.get(f"{aliased_provider}/{provider_model_id}")
+
+
+def _compute_usage_cost(model_usage: dict[str, JsonValue]) -> CostComputation:
+    return _compute_usage_cost_with_model_costs(model_usage, None)
+
+
+def _compute_usage_cost_with_model_costs(
+    model_usage: dict[str, JsonValue], model_costs: dict[str, dict[str, 
float]] | None
+) -> CostComputation:
+    if not model_usage:
+        return CostComputation(total_cost=None, warnings=["model usage is 
empty; cost omitted"])
+
+    resolved_model_costs = model_costs
+    if resolved_model_costs is None:
+        try:
+            resolved_model_costs = _load_models_dev_pricing().model_costs
+        except (URLError, OSError, ValueError, json.JSONDecodeError) as exc:
+            return CostComputation(
+                total_cost=None,
+                warnings=[f"unable to load pricing from {MODELS_DEV_API_URL}: 
{exc}"],
+            )
+
+    total_cost = 0.0
+    matched_usage = False
+    warnings: list[str] = []
+    usage_fields = {
+        "input_tokens": "input",
+        "output_tokens": "output",
+        "input_tokens_cache_read": "cache_read",
+        "input_tokens_cache_write": "cache_write",
+        "reasoning_tokens": "reasoning",
+    }
+
+    for model_id, raw_usage in sorted(model_usage.items()):
+        if not isinstance(raw_usage, dict):
+            warnings.append(f"usage for model {model_id!r} is not structured; 
cost omitted")
+            continue
+
+        cost = _lookup_model_cost(resolved_model_costs, model_id)
+        if cost is None:
+            warnings.append(f"no models.dev pricing found for model 
{model_id!r}; cost omitted")
+            continue
+
+        matched_usage = True
+        for usage_field, cost_field in usage_fields.items():
+            token_count = raw_usage.get(usage_field)
+            if not isinstance(token_count, (int, float)):
+                continue
+            if token_count == 0:
+                continue
+
+            unit_cost = cost.get(cost_field)
+            if unit_cost is None:
+                warnings.append(
+                    f"models.dev pricing for model {model_id!r} does not 
include {cost_field!r}; {usage_field} omitted"
+                )
+                continue
+
+            total_cost += float(token_count) * unit_cost / 1_000_000
+
+    if not matched_usage:
+        return CostComputation(total_cost=None, warnings=warnings)
+
+    return CostComputation(total_cost=round(total_cost, 6), warnings=warnings)
+
+
+def _extract_scores(log: Any) -> dict[str, Any]:
+    if log.results is None:
+        return {}
+
+    scores: dict[str, Any] = {}
+    for index, score in enumerate(log.results.scores, start=1):
+        score_key = _score_key(score, index, scores)
+        entry: dict[str, Any] = {
+            "key": score_key,
+            "name": score.name,
+            "scorer": score.scorer,
+            "metrics": {
+                metric.name: _normalize(metric.value) for metric in 
score.metrics.values()
+            },
+        }
+        reducer = _safe_getattr(score, "reducer")
+        if reducer is not None:
+            entry["reducer"] = reducer
+        scores[score_key] = entry
+    return scores
+
+
+def _score_key(score: Any, index: int, existing_scores: dict[str, Any]) -> str:
+    base = str(
+        _safe_getattr(score, "name")
+        or _safe_getattr(score, "scorer")
+        or f"score_{index}"
+    )
+    if base not in existing_scores:
+        return base
+
+    scorer = _safe_getattr(score, "scorer")
+    if scorer:
+        candidate = f"{base} [{scorer}]"
+        if candidate not in existing_scores:
+            return candidate
+
+    suffix = 2
+    while True:
+        candidate = f"{base} #{suffix}"
+        if candidate not in existing_scores:
+            return candidate
+        suffix += 1
+
+
+def _extract_skill_enabled_sources(log: Any) -> dict[str, JsonValue]:
+    eval_metadata = _safe_getattr(log.eval, "metadata", {}) or {}
+    top_metadata = getattr(log, "metadata", {}) or {}
+    task_args = _safe_getattr(log.eval, "task_args", {}) or {}
+    return {
+        "task_args": _normalize(task_args.get("skill_enabled")),
+        "eval_metadata": _normalize(eval_metadata.get("skill_enabled")),
+        "top_metadata": _normalize(top_metadata.get("skill_enabled")),
+    }
+
+
+def _build_coordinate_values(log: Any) -> dict[str, JsonValue]:
+    dataset = _safe_getattr(log.eval, "dataset")
+    task_identity = {
+        "task": _safe_getattr(log.eval, "task"),
+        "task_display_name": _safe_getattr(log.eval, "task_display_name"),
+        "task_registry_name": _safe_getattr(log.eval, "task_registry_name"),
+        "dataset_location": _safe_getattr(dataset, "location"),
+        "sample_ids": _normalize(_safe_getattr(dataset, "sample_ids", [])),
+    }
+    return {
+        "task": _normalize(task_identity),
+        "model": _normalize(_safe_getattr(log.eval, "model")),
+        "model_generate_config": _normalize(
+            _safe_getattr(log.eval, "model_generate_config", {})
+        ),
+        "task_args": _normalize(_safe_getattr(log.eval, "task_args", {})),
+        "skill_enabled": _normalize(_extract_skill_enabled_sources(log)),
+        "config": _normalize(
+            _safe_getattr(
+                _safe_getattr(log.eval, "config"),
+                "model_dump",
+                lambda: _safe_getattr(log.eval, "config"),
+            )()
+        ),
+        "metadata": _normalize(
+            {
+                "eval_metadata": _safe_getattr(log.eval, "metadata", {}),
+                "top_metadata": getattr(log, "metadata", {}),
+            }
+        ),
+    }
+
+
+def _summarize_log(path: str, log: Any) -> dict[str, JsonValue]:
+    stats = log.stats
+    eval_config = _safe_getattr(log.eval, "config")
+    return {
+        "path": path,
+        "status": log.status,
+        "task": _safe_getattr(log.eval, "task"),
+        "task_id": _safe_getattr(log.eval, "task_id"),
+        "run_id": _safe_getattr(log.eval, "run_id"),
+        "created": _safe_getattr(log.eval, "created"),
+        "model": _safe_getattr(log.eval, "model"),
+        "task_args": _normalize(_safe_getattr(log.eval, "task_args", {})),
+        "eval_metadata": _normalize(_safe_getattr(log.eval, "metadata", {})),
+        "metadata": _normalize(getattr(log, "metadata", {})),
+        "model_generate_config": _normalize(
+            _safe_getattr(log.eval, "model_generate_config", {})
+        ),
+        "config": _normalize(
+            eval_config.model_dump() if hasattr(eval_config, "model_dump") 
else eval_config
+        ),
+        "started_at": _safe_getattr(stats, "started_at"),
+        "completed_at": _safe_getattr(stats, "completed_at"),
+        "duration_seconds": _compute_duration_seconds(stats),
+        "model_usage": _extract_model_usage(stats),
+        "scores": _extract_scores(log),
+        "total_samples": _safe_getattr(log.results, "total_samples"),
+        "completed_samples": _safe_getattr(log.results, "completed_samples"),
+    }
+
+
+def _headline_metrics(summary: dict[str, JsonValue]) -> dict[str, float | 
None]:
+    scores = summary.get("scores", {})
+    if not isinstance(scores, dict) or not scores:
+        return {"score": None, "stderr": None}
+
+    headline_score_key = summary.get("headline_score_key")
+    if not isinstance(headline_score_key, str) or not headline_score_key:
+        return {"score": None, "stderr": None}
+
+    headline_score = scores.get(headline_score_key)
+    if not isinstance(headline_score, dict):
+        return {"score": None, "stderr": None}
+
+    metrics = headline_score.get("metrics", {})
+    if not isinstance(metrics, dict):
+        return {"score": None, "stderr": None}
+
+    score_value = metrics.get("accuracy")
+    stderr_value = metrics.get("stderr")
+    return {
+        "score": float(score_value) if isinstance(score_value, (int, float)) 
else None,
+        "stderr": float(stderr_value) if isinstance(stderr_value, (int, 
float)) else None,
+    }
+
+
+def _total_tokens(summary: dict[str, JsonValue]) -> int | None:
+    usage = summary.get("model_usage", {})
+    if not isinstance(usage, dict):
+        return None
+    total = 0
+    found = False
+    for provider_usage in usage.values():
+        if not isinstance(provider_usage, dict):
+            continue
+        token_value = provider_usage.get("total_tokens")
+        if isinstance(token_value, (int, float)):
+            total += int(token_value)
+            found = True
+    return total if found else None
+
+
+def _percent_delta(left: float | int | None, right: float | int | None) -> 
float | None:
+    if left is None or right is None:
+        return None
+    baseline = float(left)
+    if baseline == 0:
+        return None
+    return round(((float(right) - baseline) / baseline) * 100, 4)
+
+
+def _resolve_headline_score_key(
+    left_summary: dict[str, JsonValue],
+    right_summary: dict[str, JsonValue],
+    preferred_key: str | None,
+) -> tuple[str | None, list[str]]:
+    warnings: list[str] = []
+    left_scores = left_summary.get("scores", {})
+    right_scores = right_summary.get("scores", {})
+    left_keys = sorted(left_scores) if isinstance(left_scores, dict) else []
+    right_keys = sorted(right_scores) if isinstance(right_scores, dict) else []
+    shared_keys = sorted(set(left_keys) & set(right_keys))
+
+    if preferred_key:
+        if preferred_key in shared_keys:
+            return preferred_key, warnings
+        warnings.append(
+            f"headline score key {preferred_key!r} not present in both runs; 
headline score metrics omitted"
+        )
+        return None, warnings
+
+    if len(shared_keys) == 1:
+        return shared_keys[0], warnings
+
+    if not shared_keys:
+        if left_keys or right_keys:
+            warnings.append(
+                "runs do not share a common score key; headline score metrics 
omitted"
+            )
+        return None, warnings
+
+    warnings.append(
+        "multiple score keys are available; use --headline-score to choose one 
explicitly"
+    )
+    return None, warnings
+
+
+def _detect_coordinate_diffs(
+    left_summary: dict[str, JsonValue], right_summary: dict[str, JsonValue]
+) -> list[CoordinateDiff]:
+    left_coords = left_summary["coordinates"]
+    right_coords = right_summary["coordinates"]
+    assert isinstance(left_coords, dict)
+    assert isinstance(right_coords, dict)
+
+    diffs: list[CoordinateDiff] = []
+    for coordinate in sorted(set(left_coords) | set(right_coords)):
+        left_value = left_coords.get(coordinate)
+        right_value = right_coords.get(coordinate)
+        reduced_diff = _diff_value(left_value, right_value)
+        if reduced_diff is not None:
+            diff_left, diff_right = reduced_diff
+            diffs.append(
+                CoordinateDiff(coordinate=coordinate, left=diff_left, 
right=diff_right)
+            )
+    return diffs
+
+
+def _skill_enabled_warnings(summary: dict[str, JsonValue], side: str) -> 
list[str]:
+    coordinates = summary["coordinates"]
+    assert isinstance(coordinates, dict)
+    skill_enabled = coordinates.get("skill_enabled", {})
+    if not isinstance(skill_enabled, dict):
+        return []
+    values = {name: value for name, value in skill_enabled.items() if value is 
not None}
+    if len(set(json.dumps(_normalize(value), sort_keys=True) for value in 
values.values())) <= 1:
+        return []
+    rendered = ", ".join(f"{key}={value!r}" for key, value in values.items())
+    return [f"{side}: inconsistent skill_enabled values across sources 
({rendered})"]
+
+
+def _serialize_score_values(scores: Any) -> dict[str, JsonValue]:
+    if not isinstance(scores, dict):
+        return {}
+    serialized: dict[str, JsonValue] = {}
+    for name, score in sorted(scores.items()):
+        value = _safe_getattr(score, "value")
+        serialized[name] = _normalize(value)
+    return serialized
+
+
+def _serialize_sample_usage(model_usage: Any) -> dict[str, JsonValue]:
+    if not isinstance(model_usage, dict):
+        return {}
+    return _normalize(model_usage)
+
+
+def _sum_usage_total_tokens(model_usage: dict[str, JsonValue]) -> int | None:
+    total = 0
+    found = False
+    for provider_usage in model_usage.values():
+        if not isinstance(provider_usage, dict):
+            continue
+        token_value = provider_usage.get("total_tokens")
+        if isinstance(token_value, (int, float)):
+            total += int(token_value)
+            found = True
+    return total if found else None
+
+
+def _mean(values: list[float]) -> float | None:
+    if not values:
+        return None
+    return round(sum(values) / len(values), 6)
+
+
+def _deduplicate_warnings(warnings: list[str]) -> list[str]:
+    return list(dict.fromkeys(warnings))
+
+
+def _score_list_mean(score_values: dict[str, list[float]]) -> dict[str, 
JsonValue]:
+    return {name: _mean(values) for name, values in score_values.items()}
+
+
+def _score_list_min(score_values: dict[str, list[float]]) -> dict[str, 
JsonValue]:
+    return {name: min(values) if values else None for name, values in 
score_values.items()}
+
+
+def _score_list_max(score_values: dict[str, list[float]]) -> dict[str, 
JsonValue]:
+    return {name: max(values) if values else None for name, values in 
score_values.items()}
+
+
+def _aggregate_sample_entry(
+    samples: list[Any], model_costs: dict[str, dict[str, float]] | None
+) -> tuple[dict[str, JsonValue], list[str]]:
+    score_values: dict[str, list[float]] = {}
+    total_times: list[float] = []
+    working_times: list[float] = []
+    total_tokens_per_epoch: list[float] = []
+    total_costs_per_epoch: list[float] = []
+    error_count = 0
+    limit_count = 0
+    warnings: list[str] = []
+
+    for sample in samples:
+        serialized_scores = _serialize_score_values(_safe_getattr(sample, 
"scores", {}))
+        for score_name, score_value in serialized_scores.items():
+            if isinstance(score_value, (int, float)):
+                score_values.setdefault(score_name, 
[]).append(float(score_value))
+
+        total_time = _safe_getattr(sample, "total_time")
+        if isinstance(total_time, (int, float)):
+            total_times.append(float(total_time))
+
+        working_time = _safe_getattr(sample, "working_time")
+        if isinstance(working_time, (int, float)):
+            working_times.append(float(working_time))
+
+        usage = _serialize_sample_usage(_safe_getattr(sample, "model_usage", 
{}))
+        usage_total = _sum_usage_total_tokens(usage)
+        if usage_total is not None:
+            total_tokens_per_epoch.append(float(usage_total))
+        usage_cost = _compute_usage_cost_with_model_costs(usage, model_costs)
+        if usage_cost.total_cost is not None:
+            total_costs_per_epoch.append(float(usage_cost.total_cost))
+        warnings.extend(usage_cost.warnings)
+
+        if _safe_getattr(sample, "error") is not None:
+            error_count += 1
+        if _safe_getattr(sample, "limit") is not None:
+            limit_count += 1
+
+    success_count = 0
+    failure_count = 0
+    for values in score_values.values():
+        success_count += sum(1 for value in values if value >= 1)
+        failure_count += sum(1 for value in values if value <= 0)
+
+    total_tokens = int(sum(total_tokens_per_epoch)) if total_tokens_per_epoch 
else None
+    avg_tokens_per_epoch = _mean(total_tokens_per_epoch)
+    total_cost = round(sum(total_costs_per_epoch), 6) if total_costs_per_epoch 
else None
+    avg_cost_per_epoch = _mean(total_costs_per_epoch)
+
+    return (
+        {
+            "epochs": len(samples),
+            "score_values": {
+                name: [_round_number(value) for value in values]
+                for name, values in sorted(score_values.items())
+            },
+            "mean_score": _score_list_mean(score_values),
+            "min_score": _score_list_min(score_values),
+            "max_score": _score_list_max(score_values),
+            "success_count": success_count,
+            "failure_count": failure_count,
+            "avg_total_time": _mean(total_times),
+            "avg_working_time": _mean(working_times),
+            "total_tokens": total_tokens,
+            "avg_tokens_per_epoch": avg_tokens_per_epoch,
+            "total_cost": total_cost,
+            "avg_cost_per_epoch": avg_cost_per_epoch,
+            "has_error": error_count > 0,
+            "error_count": error_count,
+            "has_limit": limit_count > 0,
+            "limit_count": limit_count,
+        },
+        _deduplicate_warnings(warnings),
+    )
+
+
+def _group_samples_by_entry(path: str) -> dict[str, list[Any]]:
+    grouped: dict[str, list[Any]] = {}
+    for sample in read_eval_log_sample_summaries(path):
+        grouped.setdefault(str(sample.id), []).append(sample)
+    for samples in grouped.values():
+        samples.sort(key=lambda sample: int(sample.epoch))
+    return grouped
+
+
+def _compare_sample_entries(
+    left_path: str,
+    right_path: str,
+    model_costs: dict[str, dict[str, float]] | None,
+) -> tuple[dict[str, Any], list[str]]:
+    left_entries = _group_samples_by_entry(left_path)
+    right_entries = _group_samples_by_entry(right_path)
+
+    all_sample_ids = sorted(set(left_entries) | set(right_entries))
+    comparisons: list[dict[str, Any]] = []
+    differing_entries = 0
+    warnings: list[str] = []
+
+    for sample_id in all_sample_ids:
+        left_aggregate, left_warnings = _aggregate_sample_entry(
+            left_entries.get(sample_id, []), model_costs
+        )
+        right_aggregate, right_warnings = _aggregate_sample_entry(
+            right_entries.get(sample_id, []), model_costs
+        )
+        warnings.extend(f"sample left: {warning}" for warning in left_warnings)
+        warnings.extend(f"sample right: {warning}" for warning in 
right_warnings)
+        different_fields = [
+            field
+            for field in left_aggregate.keys()
+            if not _deep_equal(left_aggregate[field], right_aggregate[field])
+        ]
+        if different_fields:
+            differing_entries += 1
+        comparisons.append(
+            {
+                "sample_id": sample_id,
+                "different_fields": different_fields,
+                "left": left_aggregate,
+                "right": right_aggregate,
+            }
+        )
+
+    return (
+        {
+            "total_entries": len(all_sample_ids),
+            "entries_with_differences": differing_entries,
+            "comparisons": comparisons,
+        },
+        _deduplicate_warnings(warnings),
+    )
+
+
+def build_report(
+    left_path: str,
+    right_path: str,
+    expect_diff: set[str],
+    include_samples: bool,
+    headline_score: str | None = None,
+) -> dict[str, Any]:
+    left_log = read_eval_log(left_path, header_only=True)
+    right_log = read_eval_log(right_path, header_only=True)
+
+    left_summary = _summarize_log(left_path, left_log)
+    right_summary = _summarize_log(right_path, right_log)
+    left_summary["coordinates"] = _build_coordinate_values(left_log)
+    right_summary["coordinates"] = _build_coordinate_values(right_log)
+
+    headline_score_key, headline_warnings = _resolve_headline_score_key(
+        left_summary, right_summary, headline_score
+    )
+    left_summary["headline_score_key"] = headline_score_key
+    right_summary["headline_score_key"] = headline_score_key
+
+    coordinate_diffs = _detect_coordinate_diffs(left_summary, right_summary)
+    allowed_diff = _expand_expected_diff(expect_diff)
+    unexpected = [
+        diff.coordinate for diff in coordinate_diffs if diff.coordinate not in 
allowed_diff
+    ]
+
+    left_headline = _headline_metrics(left_summary)
+    right_headline = _headline_metrics(right_summary)
+    left_tokens = _total_tokens(left_summary)
+    right_tokens = _total_tokens(right_summary)
+    pricing_warnings: list[str] = []
+    model_costs: dict[str, dict[str, float]] | None = None
+    try:
+        pricing = _load_models_dev_pricing()
+        model_costs = pricing.model_costs
+    except (URLError, OSError, ValueError, json.JSONDecodeError) as exc:
+        pricing_warnings.append(
+            f"unable to load pricing from {MODELS_DEV_API_URL}: {exc}"
+        )
+    left_cost = 
_compute_usage_cost_with_model_costs(left_summary["model_usage"], model_costs)
+    right_cost = 
_compute_usage_cost_with_model_costs(right_summary["model_usage"], model_costs)
+    sample_entry_differences: dict[str, Any] | None = None
+    sample_warnings: list[str] = []
+    if include_samples:
+        sample_entry_differences, sample_warnings = _compare_sample_entries(
+            left_path, right_path, model_costs
+        )
+
+    warnings = []
+    warnings.extend(headline_warnings)
+    warnings.extend(_skill_enabled_warnings(left_summary, "left"))
+    warnings.extend(_skill_enabled_warnings(right_summary, "right"))
+    warnings.extend(pricing_warnings)
+    warnings.extend(f"left: {warning}" for warning in left_cost.warnings)
+    warnings.extend(f"right: {warning}" for warning in right_cost.warnings)
+    warnings.extend(sample_warnings)
+    if left_summary["status"] != "success":
+        warnings.append(f"left: status is {left_summary['status']!r}, results 
may be partial")
+    if right_summary["status"] != "success":
+        warnings.append(f"right: status is {right_summary['status']!r}, 
results may be partial")
+    if unexpected:
+        warnings.append(
+            "unexpected coordinate differences found: " + ", 
".join(sorted(unexpected))
+        )
+
+    return {
+        "left": left_summary,
+        "right": right_summary,
+        "expected_diff": sorted(expect_diff),
+        "headline_score_key": headline_score_key,
+        "allowed_diff_coordinates": sorted(allowed_diff),
+        "actual_differences": [
+            {"coordinate": diff.coordinate, "left": diff.left, "right": 
diff.right}
+            for diff in coordinate_diffs
+        ],
+        "unexpected_differences": sorted(unexpected),
+        "summary": {
+            "score": {
+                "left": left_headline["score"],
+                "right": right_headline["score"],
+                "delta": (right_headline["score"] - left_headline["score"])
+                if left_headline["score"] is not None
+                and right_headline["score"] is not None
+                else None,
+                "percent_delta": _percent_delta(
+                    left_headline["score"], right_headline["score"]
+                ),
+            },
+            "stderr": {
+                "left": left_headline["stderr"],
+                "right": right_headline["stderr"],
+                "delta": (right_headline["stderr"] - left_headline["stderr"])
+                if left_headline["stderr"] is not None
+                and right_headline["stderr"] is not None
+                else None,
+                "percent_delta": _percent_delta(
+                    left_headline["stderr"], right_headline["stderr"]
+                ),
+            },
+            "duration_seconds": {
+                "left": left_summary["duration_seconds"],
+                "right": right_summary["duration_seconds"],
+                "delta": (
+                    right_summary["duration_seconds"] - 
left_summary["duration_seconds"]
+                    if isinstance(left_summary["duration_seconds"], (int, 
float))
+                    and isinstance(right_summary["duration_seconds"], (int, 
float))
+                    else None
+                ),
+                "percent_delta": _percent_delta(
+                    left_summary["duration_seconds"], 
right_summary["duration_seconds"]
+                ),
+            },
+            "total_tokens": {
+                "left": left_tokens,
+                "right": right_tokens,
+                "delta": (right_tokens - left_tokens)
+                if left_tokens is not None and right_tokens is not None
+                else None,
+                "percent_delta": _percent_delta(left_tokens, right_tokens),
+            },
+            "total_cost": {
+                "left": left_cost.total_cost,
+                "right": right_cost.total_cost,
+                "delta": (right_cost.total_cost - left_cost.total_cost)
+                if left_cost.total_cost is not None and right_cost.total_cost 
is not None
+                else None,
+                "percent_delta": _percent_delta(
+                    left_cost.total_cost, right_cost.total_cost
+                ),
+            },
+        },
+        "sample_entry_differences": sample_entry_differences,
+        "warnings": _deduplicate_warnings(warnings),
+    }

Reply via email to