from __future__ import annotations import json import math from pathlib import Path import pytest from cil.metrics import ( MetricInputError, any_unsafe, candidate_diversity, collapse_rate, mean_nearest_distance_to_set, normalized_causal_action_regret, outcome_safety_violation, outcome_ptr_at_k, proxy_positive_tangent_coverage_at_k, proxy_support_distance, selector_regret_at_k, selected_unsafe, safety_label_coverage, unsafe_rate, ) from scripts import eval_metrics def test_outcome_ptr_refuses_proxy_inputs() -> None: with pytest.raises(MetricInputError, match="OutcomePTR"): outcome_ptr_at_k([0.5, 0.7], 0.6, candidates_evaluated=False) assert outcome_ptr_at_k([0.5, 0.7], 0.6, candidates_evaluated=True) == 1.0 def test_selector_regret_refuses_unevaluated_candidates() -> None: with pytest.raises(MetricInputError, match="SelectorRegret"): selector_regret_at_k([0.5, 0.7], candidates_evaluated=False) def test_normalized_causal_action_regret() -> None: assert normalized_causal_action_regret(2.0, 1.25, 1.0) == pytest.approx(0.75) assert normalized_causal_action_regret(2.0, 0.5, 1.0) > 1.0 def test_proxy_positive_tangent_coverage_is_not_outcome_ptr() -> None: generated = [[0.0, 0.1], [2.0, 2.0]] positives = [[0.0, 0.0]] assert proxy_positive_tangent_coverage_at_k( generated, positives, threshold=0.08, k=2, ) == 1.0 assert math.isclose(proxy_support_distance(generated, positives, k=2), math.sqrt(0.005)) assert math.isclose( mean_nearest_distance_to_set(generated, positives, k=2), (math.sqrt(0.005) + 2.0) / 2.0, ) assert candidate_diversity(generated, k=2) > 0.0 assert collapse_rate([[1.0, 1.0], [1.0, 1.0], [2.0, 2.0]], k=3) == pytest.approx( 2.0 / 3.0 ) def test_safety_metrics_preserve_unknown_labels() -> None: outcomes = [ {"safety_violation": False}, {"safety_violation": "yes"}, {"safety_violation": None}, ] assert outcome_safety_violation(outcomes[0]) is False assert outcome_safety_violation(outcomes[1]) is True assert outcome_safety_violation(outcomes[2]) is None assert safety_label_coverage(outcomes, k=3) == pytest.approx(2.0 / 3.0) assert unsafe_rate(outcomes, k=3) == pytest.approx(0.5) assert any_unsafe(outcomes, k=3) == 1.0 assert selected_unsafe(outcomes, selected_index=1, k=3) == 1.0 assert unsafe_rate([{"safety_violation": None}], k=1) is None def test_eval_metrics_measured_mode_requires_evaluated_candidates(tmp_path: Path) -> None: input_path = tmp_path / "rows.json" input_path.write_text( json.dumps( [ { "task_id": "pick", "seed": 0, "candidates_evaluated": False, "base_utility": 0.2, "generated_utilities": [0.3, 0.4], } ] ) ) with pytest.raises(MetricInputError, match="candidates_evaluated=true"): eval_metrics.main(["--input", str(input_path), "--out-dir", str(tmp_path / "out"), "--mode", "measured"]) def test_eval_metrics_measured_exports_success_decomposition(tmp_path: Path) -> None: input_path = tmp_path / "rows.json" input_path.write_text( json.dumps( { "rows": [ { "chart_id": "c0", "task_id": "pick", "seed": 0, "candidates_evaluated": True, "base_utility": 0.3, "base_success": False, "base_outcome": {"success": False, "safety_violation": False}, "generated_utilities": [0.2, 1.1, 0.5], "candidate_success": [False, True, False], "candidate_outcomes": [ {"success": False, "safety_violation": True}, {"success": True, "safety_violation": False}, {"success": False, "safety_violation": None}, ], "selected_index": 0, "hidden_chart_utilities": [0.4, 1.5], "predicted_scores": [0.9, 0.8, 0.1], } ] } ) ) out_dir = tmp_path / "measured_metrics" assert ( eval_metrics.main( [ "--input", str(input_path), "--out-dir", str(out_dir), "--mode", "measured", "--k", "3", "--bootstrap-samples", "20", ] ) == 0 ) metrics = json.loads((out_dir / "metrics.json").read_text()) row = metrics["rows"][0] assert row["selected_success_at_3"] == 0.0 assert row["proposal_oracle_success_at_3"] == 1.0 assert row["hidden_chart_oracle_success_at_3"] == 1.0 assert row["success_support_gap_at_3"] == 0.0 assert row["success_selector_gap_at_3"] == 1.0 assert row["ncar_to_proposal_oracle_at_3"] == pytest.approx(1.125) assert row["ncar_to_hidden_chart_oracle_at_3"] == pytest.approx(1.0833333333) assert row["support_gap_fraction_to_hidden_at_3"] == pytest.approx(0.3333333333) assert row["selector_gap_fraction_to_hidden_at_3"] == pytest.approx(0.75) assert row["proposal_oracle_utility_gain_over_base_at_3"] == pytest.approx(0.8) assert row["base_safety_label_known"] == 1.0 assert row["base_unsafe_known"] == 0.0 assert row["generated_safety_label_coverage_at_3"] == pytest.approx(2.0 / 3.0) assert row["generated_unsafe_rate_known_at_3"] == pytest.approx(0.5) assert row["any_generated_unsafe_known_at_3"] == 1.0 assert row["selected_safety_label_known_at_3"] == 1.0 assert row["selected_unsafe_known_at_3"] == 1.0 assert row["proposal_oracle_safety_label_known_at_3"] == 1.0 assert row["proposal_oracle_unsafe_known_at_3"] == 0.0 def test_eval_metrics_measured_exports_safety_coverage_without_false_zero( tmp_path: Path, ) -> None: input_path = tmp_path / "rows.json" input_path.write_text( json.dumps( { "rows": [ { "chart_id": "c0", "task_id": "pick", "seed": 0, "candidates_evaluated": True, "base_utility": 0.3, "base_success": False, "base_outcome": {"success": False, "safety_violation": None}, "generated_utilities": [0.2, 0.4], "candidate_success": [False, False], "candidate_outcomes": [ {"success": False, "safety_violation": None}, {"success": False, "safety_violation": None}, ], "selected_index": 0, } ] } ) ) out_dir = tmp_path / "measured_metrics_null_safety" assert ( eval_metrics.main( [ "--input", str(input_path), "--out-dir", str(out_dir), "--mode", "measured", "--k", "2", "--bootstrap-samples", "20", ] ) == 0 ) metrics = json.loads((out_dir / "metrics.json").read_text()) row = metrics["rows"][0] assert row["base_safety_label_known"] == 0.0 assert row["generated_safety_label_coverage_at_2"] == 0.0 assert row["selected_safety_label_known_at_2"] == 0.0 assert row["proposal_oracle_safety_label_known_at_2"] == 0.0 assert "base_unsafe_known" not in row assert "generated_unsafe_rate_known_at_2" not in row assert "any_generated_unsafe_known_at_2" not in row assert "selected_unsafe_known_at_2" not in row assert "proposal_oracle_unsafe_known_at_2" not in row def test_eval_metrics_omits_unstable_ncar_when_oracle_matches_base(tmp_path: Path) -> None: input_path = tmp_path / "rows.json" input_path.write_text( json.dumps( { "rows": [ { "chart_id": "c0", "task_id": "pick", "seed": 0, "candidates_evaluated": True, "base_utility": 0.5, "generated_utilities": [0.4, 0.5], "selected_index": 0, } ] } ) ) out_dir = tmp_path / "measured_metrics_unstable_ncar" assert ( eval_metrics.main( [ "--input", str(input_path), "--out-dir", str(out_dir), "--mode", "measured", "--k", "2", "--bootstrap-samples", "20", ] ) == 0 ) row = json.loads((out_dir / "metrics.json").read_text())["rows"][0] assert "ncar_to_proposal_oracle_at_2" not in row def test_eval_metrics_proxy_exports_json_and_latex(tmp_path: Path) -> None: input_path = tmp_path / "rows.json" input_path.write_text( json.dumps( { "rows": [ { "chart_id": "c0", "task_id": "pick", "seed": 0, "generated_tangents": [[0.0, 0.1], [1.0, 1.0]], "positive_tangents": [[0.0, 0.0]], "negative_tangents": [[2.0, 2.0]], } ] } ) ) out_dir = tmp_path / "proxy_metrics" assert ( eval_metrics.main( [ "--input", str(input_path), "--out-dir", str(out_dir), "--mode", "proxy", "--k", "2", "--thresholds", "0.20,0.40", "--bootstrap-samples", "20", ] ) == 0 ) metrics = json.loads((out_dir / "metrics.json").read_text()) assert metrics["rows"][0]["pptc_at_2_thr_0p20"] == 1.0 assert "proxy_support_distance_at_2" in metrics["rows"][0] assert "mean_positive_distance_at_2" in metrics["rows"][0] assert "mean_negative_distance_at_2" in metrics["rows"][0] assert (out_dir / "table.tex").exists() assert (out_dir / "report.md").exists() for filename in ( "config.yaml", "command.txt", "git_hash.txt", "data_hash.txt", "split_hash.txt", "train.log", "eval.log", "metrics_by_task.json", "metrics_by_seed.json", ): assert (out_dir / filename).exists() def test_eval_metrics_no_markdown_report_removes_stale_report(tmp_path: Path) -> None: input_path = tmp_path / "rows.json" input_path.write_text( json.dumps( { "split_hash": "split-demo", "rows": [ { "chart_id": "c0", "task_id": "pick", "seed": 0, "generated_tangents": [[0.0, 0.1]], "positive_tangents": [[0.0, 0.0]], } ], } ) ) out_dir = tmp_path / "proxy_metrics" (out_dir).mkdir() (out_dir / "report.md").write_text("stale\n") assert ( eval_metrics.main( [ "--input", str(input_path), "--out-dir", str(out_dir), "--mode", "proxy", "--k", "1", "--bootstrap-samples", "20", "--no-markdown-report", ] ) == 0 ) assert not (out_dir / "report.md").exists() assert (out_dir / "config.yaml").exists() assert (out_dir / "command.txt").exists() assert (out_dir / "git_hash.txt").exists() assert (out_dir / "data_hash.txt").exists() assert (out_dir / "split_hash.txt").read_text().strip() == "split-demo"