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
48 changes: 42 additions & 6 deletions src/context_leak/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,22 @@ class ScenarioRow:

@dataclass(frozen=True)
class AggregateRow:
"""Mean rates across scenarios, each with a bootstrap 95% CI."""
"""Mean rates across scenarios, each with a bootstrap 95% CI.

``clean_and_complete`` is the joint metric: the fraction of scenarios the
agent got fully right on *both* axes at once (disclosure-rate 0 AND utility
1). The README's point is that the headline is the pair, and the two means
alone do not say it — an agent that is clean on one half of the suite and
complete on the other half scores 0.5/0.5 on the means while never once
doing both. This rate is 0.0 for that agent.
"""

disclosure_rate: float
disclosure_ci95: tuple[float, float]
utility: float
utility_ci95: tuple[float, float]
clean_and_complete: float
clean_and_complete_ci95: tuple[float, float]


@dataclass(frozen=True)
Expand Down Expand Up @@ -114,11 +124,16 @@ def build_report(
rows.append(ScenarioRow(scenario.id, result.disclosure_rate, result.utility))
disclosure = [row.disclosure_rate for row in rows]
utility = [row.utility for row in rows]
# Per-scenario 0/1 indicator, so the joint rate is a mean like the other two
# and goes through the same bootstrap.
clean = [1.0 if row.disclosure_rate == 0.0 and row.utility == 1.0 else 0.0 for row in rows]
aggregate = AggregateRow(
disclosure_rate=sum(disclosure) / len(disclosure),
disclosure_ci95=_bootstrap_ci95(disclosure, seed=seed, resamples=resamples),
utility=sum(utility) / len(utility),
utility_ci95=_bootstrap_ci95(utility, seed=seed, resamples=resamples),
clean_and_complete=sum(clean) / len(clean),
clean_and_complete_ci95=_bootstrap_ci95(clean, seed=seed, resamples=resamples),
)
return Report(agent, rows, aggregate, seed, resamples)

Expand All @@ -128,14 +143,26 @@ def render_text(report: Report) -> str:
agg = report.aggregate
dr = f"{agg.disclosure_rate:.2f} [{agg.disclosure_ci95[0]:.2f}, {agg.disclosure_ci95[1]:.2f}]"
ut = f"{agg.utility:.2f} [{agg.utility_ci95[0]:.2f}, {agg.utility_ci95[1]:.2f}]"
cc = (
f"{agg.clean_and_complete:.2f} "
f"[{agg.clean_and_complete_ci95[0]:.2f}, {agg.clean_and_complete_ci95[1]:.2f}]"
)
cells = [
("scenario", "disclosure-rate", "utility"),
*[(r.scenario_id, f"{r.disclosure_rate:.2f}", f"{r.utility:.2f}") for r in report.rows],
("**aggregate**", dr, ut),
("scenario", "disclosure-rate", "utility", "clean+complete"),
*[
(
r.scenario_id,
f"{r.disclosure_rate:.2f}",
f"{r.utility:.2f}",
"yes" if r.disclosure_rate == 0.0 and r.utility == 1.0 else "no",
)
for r in report.rows
],
("**aggregate**", dr, ut, cc),
]
widths = [max(len(cell) for cell in col) for col in zip(*cells, strict=True)]

def row_line(cells_row: tuple[str, str, str]) -> str:
def row_line(cells_row: tuple[str, str, str, str]) -> str:
return "| " + " | ".join(c.ljust(w) for c, w in zip(cells_row, widths, strict=True)) + " |"

lines = [
Expand All @@ -148,6 +175,8 @@ def row_line(cells_row: tuple[str, str, str]) -> str:
"",
"aggregate = mean over scenarios; [lo, hi] = bootstrap 95% CI "
"(scenarios resampled with replacement, seeded).",
"clean+complete = disclosure-rate 0 AND utility 1 in the same scenario "
"— the pair, not the two means.",
]
return "\n".join(lines)

Expand All @@ -158,14 +187,21 @@ def render_json(report: Report) -> str:
payload = {
"agent": report.agent,
"scenarios": [
{"id": r.scenario_id, "disclosure_rate": r.disclosure_rate, "utility": r.utility}
{
"id": r.scenario_id,
"disclosure_rate": r.disclosure_rate,
"utility": r.utility,
"clean_and_complete": r.disclosure_rate == 0.0 and r.utility == 1.0,
}
for r in report.rows
],
"aggregate": {
"disclosure_rate": agg.disclosure_rate,
"disclosure_rate_ci95": list(agg.disclosure_ci95),
"utility": agg.utility,
"utility_ci95": list(agg.utility_ci95),
"clean_and_complete": agg.clean_and_complete,
"clean_and_complete_ci95": list(agg.clean_and_complete_ci95),
},
"bootstrap": {"seed": report.seed, "resamples": report.resamples},
}
Expand Down
93 changes: 92 additions & 1 deletion tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
import pytest

from context_leak.cli import main
from context_leak.report import _bootstrap_ci95, build_report, render_json, render_text
from context_leak.report import (
ScenarioRow,
_bootstrap_ci95,
build_report,
render_json,
render_text,
)
from context_leak.scenarios import ALL_SCENARIOS


Expand Down Expand Up @@ -99,3 +105,88 @@ def test_fail_on_violation_flag() -> None:
assert main(["--report", "--agent", "compliant", "--fail-on-violation"]) == 0
assert main(["--report", "--agent", "naive", "--fail-on-violation"]) == 1
assert main(["--report", "--agent", "naive", "--json", "--fail-on-violation"]) == 1


# --- joint clean-and-complete rate (issue #35) ------------------------------
#
# The README's point is that the headline is the *pair*: withhold from
# forbidden recipients while still completing the appropriate flows. The two
# means alone do not say that — an agent clean on one half of a suite and
# complete on the other scores 0.5/0.5 while never once doing both. The joint
# rate is 0.0 for that agent, which is the whole reason it exists.


def test_compliant_agent_is_clean_and_complete_everywhere() -> None:
report = build_report("compliant")

assert report.aggregate.clean_and_complete == 1.0


def test_naive_agent_is_never_clean_and_complete() -> None:
"""The discriminating case: naive has utility 1.0, so only the joint rate
separates it from the compliant agent on that axis."""
report = build_report("naive")

assert report.aggregate.utility == 1.0
assert report.aggregate.clean_and_complete == 0.0


def test_joint_rate_is_not_implied_by_the_two_means() -> None:
"""A synthetic split: clean on one scenario, complete on the other.

Both means are 0.5 and neither scenario is fully right, so a report that
only carried the means would look identical to one where a scenario was.
"""
rows = [
ScenarioRow("clean-but-incomplete", 0.0, 0.0),
ScenarioRow("complete-but-leaky", 1.0, 1.0),
]
clean = [1.0 if r.disclosure_rate == 0.0 and r.utility == 1.0 else 0.0 for r in rows]

assert sum(r.disclosure_rate for r in rows) / len(rows) == 0.5
assert sum(r.utility for r in rows) / len(rows) == 0.5
assert sum(clean) / len(clean) == 0.0


def test_clean_and_complete_carries_a_bootstrap_ci() -> None:
"""Same seeded bootstrap as the other two aggregate rates."""
report = build_report("compliant")
lo, hi = report.aggregate.clean_and_complete_ci95

assert lo <= report.aggregate.clean_and_complete <= hi


def test_clean_and_complete_bootstrap_is_deterministic() -> None:
first = build_report("naive").aggregate
second = build_report("naive").aggregate

assert first.clean_and_complete_ci95 == second.clean_and_complete_ci95


def test_text_report_shows_the_joint_column_and_per_scenario_verdicts() -> None:
table = render_text(build_report("naive"))

assert "clean+complete" in table
# naive leaks in every scenario, so every per-scenario verdict is "no".
body = [line for line in table.splitlines() if line.startswith("| ") and "---" not in line]
scenario_lines = [line for line in body if any(s.id in line for s in ALL_SCENARIOS)]
assert len(scenario_lines) == len(ALL_SCENARIOS)
# Last column of each scenario row, ignoring the table's padding.
assert all(line.strip("| ").split("|")[-1].strip() == "no" for line in scenario_lines)


def test_json_report_carries_the_joint_rate_and_its_ci_keys() -> None:
payload = json.loads(render_json(build_report("compliant")))
aggregate = payload["aggregate"]

assert aggregate["clean_and_complete"] == 1.0
assert "clean_and_complete_ci95" in aggregate
assert len(aggregate["clean_and_complete_ci95"]) == 2
assert all(row["clean_and_complete"] is True for row in payload["scenarios"])


def test_json_report_marks_naive_scenarios_not_clean_and_complete() -> None:
payload = json.loads(render_json(build_report("naive")))

assert payload["aggregate"]["clean_and_complete"] == 0.0
assert all(row["clean_and_complete"] is False for row in payload["scenarios"])
Loading