Skip to content

Commit 4c68e48

Browse files
authored
Fix Lean usability evaluation isolation (#1267)
* Fix Lean usability evaluation isolation * fix(eval): preserve recovery failure identity * fix(eval): isolate Lean recovery runner * fix(eval): bind recovery evaluator identity
1 parent b069742 commit 4c68e48

13 files changed

Lines changed: 821 additions & 50 deletions

File tree

benchmarks/config/lean-usability-v1.json

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{
66
"case_id": "mathlib-premise-retrieval",
77
"cue_level": "AFFORDANCE",
8-
"prompt": "Repair and independently verify the exact Lean MATHLIB theorem `∀ x : ℝ, x ^ 2 ≥ 0`. Use any suitable atomic mathematical operations; premise retrieval is available if useful, but do not rely only on a theorem name recalled from memory. Report any selected premise and replay evidence, the final proof, and verification evidence.",
8+
"prompt": "Using the exposed atomic mathematical tools—not source code, repository indexes, or a local Lean process—repair and independently verify the exact Lean MATHLIB theorem `∀ x : ℝ, x ^ 2 ≥ 0`. Premise retrieval is available if useful, but do not rely only on a theorem name recalled from memory. Report any selected premise and replay evidence, the final proof, and verification evidence.",
99
"expected_capability_ids": [
1010
"lean.check"
1111
],
@@ -52,29 +52,29 @@
5252
{
5353
"capability_id": "lean.declaration.search",
5454
"required_output_fields": [
55-
"environment_digest",
56-
"lean_version",
57-
"lean_commit",
58-
"mathlib_commit",
59-
"declarations.0.name",
60-
"declarations.0.type"
55+
"result.environment_digest",
56+
"result.lean_version",
57+
"result.lean_commit",
58+
"result.mathlib_commit",
59+
"result.declarations.0.name",
60+
"result.declarations.0.type"
6161
],
6262
"expected_output_values": {
63-
"declarations.0.name": "irrational_sqrt_two"
63+
"result.declarations.0.name": "irrational_sqrt_two"
6464
}
6565
},
6666
{
6767
"capability_id": "lean.declaration.inspect",
6868
"required_output_fields": [
69-
"environment_digest",
70-
"lean_version",
71-
"lean_commit",
72-
"mathlib_commit",
73-
"declaration.name",
74-
"declaration.type"
69+
"result.environment_digest",
70+
"result.lean_version",
71+
"result.lean_commit",
72+
"result.mathlib_commit",
73+
"result.declaration.name",
74+
"result.declaration.type"
7575
],
7676
"expected_output_values": {
77-
"declaration.name": "irrational_sqrt_two"
77+
"result.declaration.name": "irrational_sqrt_two"
7878
}
7979
}
8080
],

benchmarks/tooling/codex_visibility.py

Lines changed: 223 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
import hashlib
88
import json
99
import os
10+
import re
11+
import shutil
1012
import tempfile
1113
import time
14+
from collections import defaultdict
1215
from collections.abc import Mapping
1316
from enum import StrEnum
1417
from pathlib import Path
@@ -49,6 +52,12 @@
4952
"no_proxy",
5053
)
5154
_MCP_TOOL_APPROVAL_MODE = "approve"
55+
_SKILLS_BLOCK = re.compile(r"<skills_instructions>.*?</skills_instructions>", re.DOTALL)
56+
_SKILL_ENTRY = re.compile(
57+
r"^- (?P<name>[^:\n]+): .* \((?P<kind>file|environment resource|"
58+
r"orchestrator resource|custom resource): (?P<source>[^)\n]+)\)$",
59+
re.MULTILINE,
60+
)
5261

5362

5463
class CueLevel(StrEnum):
@@ -357,6 +366,11 @@ def classify_visibility(
357366
"mcp_wire_bytes": telemetry.get("mcp_wire_bytes", 0),
358367
"mcp_model_visible_bytes": telemetry.get("mcp_model_visible_bytes", 0),
359368
"mcp_logical_payload_bytes": telemetry.get("mcp_logical_payload_bytes", 0),
369+
"empty_payload_probe_count": telemetry.get("empty_payload_probe_count", 0),
370+
"failed_operation_attempt_count": telemetry.get(
371+
"failed_operation_attempt_count", 0
372+
),
373+
"repeated_error_count": telemetry.get("repeated_error_count", 0),
360374
}
361375

362376

@@ -455,6 +469,7 @@ def _codex_arguments(
455469
"--ephemeral",
456470
"--skip-git-repo-check",
457471
"--ignore-user-config",
472+
"--ignore-rules",
458473
"-C",
459474
str(workspace),
460475
"-s",
@@ -484,13 +499,146 @@ def _codex_arguments(
484499
return (*arguments, prompt)
485500

486501

487-
def _command_version(workspace: Path) -> str:
502+
def _prepare_isolated_codex_environment(
503+
root: Path,
504+
*,
505+
source_environment: Mapping[str, str] | None = None,
506+
) -> tuple[Mapping[str, str], dict[str, Any]]:
507+
"""Build a clean Codex HOME/CODEX_HOME and copy authentication only."""
508+
509+
source = os.environ if source_environment is None else source_environment
510+
isolated_home = root / "home"
511+
isolated_codex_home = root / "codex-home"
512+
isolated_home.mkdir(parents=True)
513+
isolated_codex_home.mkdir()
514+
source_home = Path(source.get("HOME", str(Path.home())))
515+
source_codex_home = Path(source.get("CODEX_HOME", source_home / ".codex"))
516+
source_auth = source_codex_home / "auth.json"
517+
auth_seeded = source_auth.is_file()
518+
if auth_seeded:
519+
target_auth = isolated_codex_home / "auth.json"
520+
shutil.copyfile(source_auth, target_auth)
521+
target_auth.chmod(0o600)
522+
environment = dict(
523+
operator_environment(
524+
source=source,
525+
include=_CODEX_ENVIRONMENT,
526+
declared={
527+
"HOME": str(isolated_home),
528+
"CODEX_HOME": str(isolated_codex_home),
529+
},
530+
)
531+
)
532+
return environment, {
533+
"schema_version": "1",
534+
"home_isolated": True,
535+
"codex_home_isolated": True,
536+
"user_config_loaded": False,
537+
"user_rules_loaded": False,
538+
"authentication_seeded": auth_seeded,
539+
}
540+
541+
542+
def _normalized_skill_source(
543+
source: str,
544+
*,
545+
workspace: Path,
546+
environment: Mapping[str, str],
547+
) -> tuple[str, bool]:
548+
roots = (
549+
("$CODEX_HOME", Path(environment["CODEX_HOME"])),
550+
("$HOME", Path(environment["HOME"])),
551+
("$WORKSPACE", workspace),
552+
)
553+
candidate = Path(source)
554+
if not candidate.is_absolute():
555+
return source, False
556+
for label, root in roots:
557+
try:
558+
relative = candidate.relative_to(root)
559+
except ValueError:
560+
continue
561+
return str(Path(label) / relative), False
562+
return source, True
563+
564+
565+
def _inspect_codex_skill_surface(
566+
workspace: Path,
567+
environment: Mapping[str, str],
568+
) -> dict[str, Any]:
569+
"""Render and record the skills actually visible to the evaluated Codex."""
570+
571+
result = run_operator_command(
572+
"codex",
573+
("debug", "prompt-input", "evaluation skill-surface snapshot"),
574+
cwd=workspace,
575+
timeout_seconds=30,
576+
stdout_limit_bytes=4 * 1024 * 1024,
577+
stderr_limit_bytes=1024 * 1024,
578+
environment=environment,
579+
)
580+
if result.status is not ToolCommandStatus.EXITED or result.exit_code != 0:
581+
raise RuntimeError("codex skill-surface inspection failed")
582+
try:
583+
messages = json.loads(result.stdout)
584+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
585+
raise RuntimeError(
586+
"codex skill-surface inspection returned invalid JSON"
587+
) from error
588+
blocks = [
589+
match.group(0)
590+
for message in messages
591+
if isinstance(message, Mapping)
592+
for content in message.get("content", [])
593+
if isinstance(content, Mapping) and isinstance(content.get("text"), str)
594+
for match in _SKILLS_BLOCK.finditer(content["text"])
595+
]
596+
if len(blocks) != 1:
597+
raise RuntimeError("codex prompt must expose exactly one skill surface")
598+
records = []
599+
external_file_sources = []
600+
for match in _SKILL_ENTRY.finditer(blocks[0]):
601+
source, external = _normalized_skill_source(
602+
match.group("source"),
603+
workspace=workspace,
604+
environment=environment,
605+
)
606+
record = {
607+
"name": match.group("name"),
608+
"source_kind": match.group("kind"),
609+
"source": source,
610+
}
611+
records.append(record)
612+
if external and match.group("kind") == "file":
613+
external_file_sources.append(source)
614+
candidate_entries = [
615+
line for line in blocks[0].splitlines() if line.startswith("- ")
616+
]
617+
if len(records) != len(candidate_entries):
618+
raise RuntimeError("codex skill-surface entries use an unknown format")
619+
normalized_block = blocks[0]
620+
for variable in ("CODEX_HOME", "HOME"):
621+
normalized_block = normalized_block.replace(
622+
environment[variable], f"${variable}"
623+
)
624+
normalized_block = normalized_block.replace(str(workspace), "$WORKSPACE")
625+
return {
626+
"skill_count": len(records),
627+
"skills": records,
628+
"external_file_sources": sorted(external_file_sources),
629+
"model_visible_instructions_sha256": _sha256_bytes(
630+
normalized_block.encode("utf-8")
631+
),
632+
}
633+
634+
635+
def _command_version(workspace: Path, environment: Mapping[str, str]) -> str:
488636
result = run_operator_command(
489637
"codex",
490638
("--version",),
491639
cwd=workspace,
492640
timeout_seconds=30,
493-
environment=operator_environment(include=_CODEX_ENVIRONMENT),
641+
environment=environment,
494642
)
495643
if result.status is not ToolCommandStatus.EXITED or result.exit_code != 0:
496644
raise RuntimeError("codex --version failed")
@@ -508,11 +656,11 @@ def _run_case(
508656
mcp_url: str,
509657
timeout_seconds: float,
510658
tool_mode: ToolMode,
659+
environment: Mapping[str, str],
511660
) -> dict[str, Any]:
512661
stem = f"{case.case_id}-r{repetition:02d}"
513662
transcript_path = output / f"{stem}.jsonl"
514663
stderr_path = output / f"{stem}.stderr"
515-
environment = operator_environment(include=_CODEX_ENVIRONMENT)
516664
command_start = time.monotonic()
517665
result = run_operator_command(
518666
"codex",
@@ -619,6 +767,52 @@ def _validate_mcp_url(value: str) -> None:
619767
def _build_summary(runs: list[dict[str, Any]]) -> dict[str, Any]:
620768
"""Aggregate per-run observations into the report summary block."""
621769

770+
runs_by_case: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
771+
for run in runs:
772+
runs_by_case[run["case_id"]].append(run)
773+
774+
def rate(numerator: int, denominator: int) -> float | None:
775+
return round(numerator / denominator, 6) if denominator else None
776+
777+
case_repetition_metrics = []
778+
for case_id, case_runs in sorted(runs_by_case.items()):
779+
run_count = len(case_runs)
780+
satisfied = sum(
781+
run["classification"]["contract_satisfied"] for run in case_runs
782+
)
783+
command_failures = sum(
784+
run["command"]["status"] != ToolCommandStatus.EXITED
785+
or run["command"]["exit_code"] != 0
786+
for run in case_runs
787+
)
788+
failed_attempts = sum(
789+
run["classification"]["failed_operation_attempt_count"] for run in case_runs
790+
)
791+
repeated_errors = sum(
792+
run["classification"]["repeated_error_count"] for run in case_runs
793+
)
794+
runs_with_empty_probe = sum(
795+
run["classification"]["empty_payload_probe_count"] > 0 for run in case_runs
796+
)
797+
case_repetition_metrics.append(
798+
{
799+
"case_id": case_id,
800+
"run_count": run_count,
801+
"command_failure_count": command_failures,
802+
"contract_satisfied_count": satisfied,
803+
"contract_satisfaction_rate": rate(satisfied, run_count),
804+
"empty_payload_probe_count": sum(
805+
run["classification"]["empty_payload_probe_count"]
806+
for run in case_runs
807+
),
808+
"runs_with_empty_payload_probe": runs_with_empty_probe,
809+
"empty_payload_probe_run_rate": rate(runs_with_empty_probe, run_count),
810+
"failed_operation_attempt_count": failed_attempts,
811+
"repeated_error_count": repeated_errors,
812+
"repeated_error_rate": rate(repeated_errors, failed_attempts),
813+
}
814+
)
815+
622816
return {
623817
"run_count": len(runs),
624818
"command_failure_count": sum(
@@ -671,6 +865,18 @@ def _build_summary(runs: list[dict[str, Any]]) -> dict[str, Any]:
671865
sum(run["command"]["elapsed_seconds"] for run in runs), 6
672866
),
673867
},
868+
"recovery_totals": {
869+
"empty_payload_probe_count": sum(
870+
run["classification"]["empty_payload_probe_count"] for run in runs
871+
),
872+
"failed_operation_attempt_count": sum(
873+
run["classification"]["failed_operation_attempt_count"] for run in runs
874+
),
875+
"repeated_error_count": sum(
876+
run["classification"]["repeated_error_count"] for run in runs
877+
),
878+
},
879+
"case_repetition_metrics": case_repetition_metrics,
674880
}
675881

676882

@@ -696,9 +902,18 @@ def main() -> None:
696902
raise SystemExit(f"output directory already exists: {output}")
697903
surface = asyncio.run(inspect_surface(args.mcp_url, args.timeout_seconds))
698904
output.mkdir(parents=True)
699-
with tempfile.TemporaryDirectory(prefix="jacobian-codex-visibility-") as raw:
905+
with (
906+
tempfile.TemporaryDirectory(prefix="jacobian-codex-visibility-") as raw,
907+
tempfile.TemporaryDirectory(prefix="jacobian-codex-isolation-") as isolated,
908+
):
700909
workspace = Path(raw)
701-
codex_version = _command_version(workspace)
910+
environment, isolation = _prepare_isolated_codex_environment(Path(isolated))
911+
skill_surface = _inspect_codex_skill_surface(workspace, environment)
912+
if skill_surface["external_file_sources"]:
913+
raise RuntimeError(
914+
"isolated Codex prompt exposed external file-backed skills"
915+
)
916+
codex_version = _command_version(workspace, environment)
702917
runs = [
703918
_run_case(
704919
case=case,
@@ -710,6 +925,7 @@ def main() -> None:
710925
mcp_url=args.mcp_url,
711926
timeout_seconds=args.timeout_seconds,
712927
tool_mode=args.tool_mode,
928+
environment=environment,
713929
)
714930
for case in selected_cases
715931
for repetition in range(1, args.repetitions + 1)
@@ -732,6 +948,8 @@ def main() -> None:
732948
"telemetry_parser_sha256": _sha256_bytes(
733949
(_ROOT / "src/jacobian/eval/telemetry.py").read_bytes()
734950
),
951+
"isolation": isolation,
952+
"skill_surface": skill_surface,
735953
},
736954
"codex_version": codex_version,
737955
"model": args.model,

0 commit comments

Comments
 (0)