diff --git a/src/bird_interact_agents/cloud/cli.py b/src/bird_interact_agents/cloud/cli.py index 370f3dbf..1df26763 100644 --- a/src/bird_interact_agents/cloud/cli.py +++ b/src/bird_interact_agents/cloud/cli.py @@ -151,6 +151,15 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: sp_build = sub.add_parser("build") sp_build.add_argument("--force", action="store_true") + # DEV-1553: `bird-interact-cloud submission` — generate a + # BIRD-INTERACT-1.0 a-Interact submission directory from existing + # cloud runs. Subparser-registration lives in reports.cli so the + # cloud CLI module doesn't drag in the reports package at import time + # for non-submission subcommands. + from bird_interact_agents.reports import cli as _reports_cli + + _reports_cli.add_subparser(sub) + ns = p.parse_args(argv) # `--subscription-auth` / `--no-subscription-auth` is required on submit @@ -359,6 +368,10 @@ def main(argv: Sequence[str] | None = None) -> int: f"{row['mode']} {row['status']} {row['done']}/{row['total']}" ) return 0 + if ns.subcommand == "submission": + from bird_interact_agents.reports import cli as _reports_cli + + return _reports_cli.run_submission(ns) if ns.subcommand == "build": from bird_interact_agents import paths from bird_interact_agents.cloud import image diff --git a/src/bird_interact_agents/paths.py b/src/bird_interact_agents/paths.py index 5b1bb0d7..1c44113b 100644 --- a/src/bird_interact_agents/paths.py +++ b/src/bird_interact_agents/paths.py @@ -294,3 +294,20 @@ def results_root() -> Path: def benchmarks_root() -> Path: """pytest-benchmark output (committed).""" return main_checkout_root() / ".benchmarks" + + +def reports_root() -> Path: + """BIRD-INTERACT-1.0 submission reports (DEV-1553). Worktree-safe. + + Honors ``BIRD_REPORTS_ROOT``; otherwise the main checkout's + ``reports/`` so all worktree-generated submissions aggregate in one + place. Creates the directory lazily (mirrors ``runs_root``). + """ + override = os.environ.get("BIRD_REPORTS_ROOT") + if override: + p = Path(override).expanduser() + p.mkdir(parents=True, exist_ok=True) + return p + path = main_checkout_root() / "reports" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/src/bird_interact_agents/reports/__init__.py b/src/bird_interact_agents/reports/__init__.py new file mode 100644 index 00000000..52f0a661 --- /dev/null +++ b/src/bird_interact_agents/reports/__init__.py @@ -0,0 +1,13 @@ +"""BIRD-INTERACT-1.0 submission report generator (DEV-1553). + +Converts an existing cloud run (or per-instance selection across runs) +into the JSONL + manifest layout that the leaderboard at +``bird.bench25@gmail.com`` expects. Reconstructs ``prompt_flow`` from +existing ``runs////.trajectory.json`` and +``results//cloud//results.db`` — no harness-runtime +changes required. + +a-Interact custom-agent setting only. The Section VI Universal Cost +Scheme is THE contract: fixed ``ask=2 / submit=3 / execute=1``; +everything else token-aware via the ``in<250 AND out<1000`` rule. +""" diff --git a/src/bird_interact_agents/reports/action_canonicalize.py b/src/bird_interact_agents/reports/action_canonicalize.py new file mode 100644 index 00000000..33915af4 --- /dev/null +++ b/src/bird_interact_agents/reports/action_canonicalize.py @@ -0,0 +1,110 @@ +"""MCP-tool-name → upstream-canonical action-string mapping. + +The bird-interact-tools MCP wrappers (and the harness's top-level +``ask_user`` tool) map to the same short action names upstream's +``eval_react_bird_interact.py`` emits: ``submit``, ``execute``, ``ask``, +``get_schema``, ``get_all_column_meanings``, ``get_column_meaning``, +``get_all_external_knowledge_names``, ``get_knowledge_definition``, +``get_all_knowledge_definitions``. Anything else falls through to +``()`` so the trajectory is preserved verbatim. + +Section VI prose names (``ask_user`` / ``submit_sql`` / ``execute_sql``) +are exposed via ``SECTION_VI_NAME_TO_CANONICAL`` for documentation. +""" + +from __future__ import annotations + +import json +from typing import Any + + +# (Tool name, canonical name, has-args). Order: most-specific first. +# The bird-interact-tools MCP server exposes `ask_user` AND +# `submit_query` AND `execute_sql` under the `mcp__bird-interact-tools__` +# prefix; the bare `ask_user` entry covers harnesses (e.g. the claude_sdk +# top-level tool) that register the tool directly. Both must canonicalise +# to the Section VI fixed-cost names. +_KNOWN: list[tuple[str, str]] = [ + ("mcp__bird-interact-tools__submit_query", "submit"), + ("mcp__bird-interact-tools__execute_sql", "execute"), + ("mcp__bird-interact-tools__ask_user", "ask"), + ("ask_user", "ask"), + ("mcp__bird-interact-tools__get_schema", "get_schema"), + ("mcp__bird-interact-tools__get_all_column_meanings", "get_all_column_meanings"), + ("mcp__bird-interact-tools__get_column_meaning", "get_column_meaning"), + ( + "mcp__bird-interact-tools__get_all_external_knowledge_names", + "get_all_external_knowledge_names", + ), + ( + "mcp__bird-interact-tools__get_knowledge_definition", + "get_knowledge_definition", + ), + ( + "mcp__bird-interact-tools__get_all_knowledge_definitions", + "get_all_knowledge_definitions", + ), +] + +CANONICAL_NAME_MAP: dict[str, str] = dict(_KNOWN) + +# Section VI prose ↔ upstream short form. Documented here for clarity. +SECTION_VI_NAME_TO_CANONICAL: dict[str, str] = { + "ask_user": "ask", + "submit_sql": "submit", + "execute_sql": "execute", +} + + +def _compact(obj: Any) -> str: + return json.dumps(obj, separators=(",", ":"), sort_keys=False) + + +def _sql_from_input(tool_input: dict[str, Any]) -> str: + """submit_query carries SQL in ``query_json`` (slayer mode) or + ``query`` (raw mode); execute_sql uses ``query``.""" + for key in ("query_json", "query", "sql"): + if key in tool_input: + return str(tool_input[key]) + return _compact(tool_input) + + +def canonicalize_action(tool_name: str, tool_input: dict[str, Any]) -> str: + """Return the canonical action string for one tool call. + + * ``submit_query`` / ``execute_sql`` → ``submit()`` / ``execute()`` + * ``ask_user`` → ``ask()`` + * Zero-arg helpers → ``()``. + * Arg-bearing helpers (``get_column_meaning`` / + ``get_knowledge_definition``) → ``()``. + * Unknown tools → ``()``. + """ + if tool_name == "mcp__bird-interact-tools__submit_query": + return f"submit({_sql_from_input(tool_input)})" + if tool_name == "mcp__bird-interact-tools__execute_sql": + return f"execute({_sql_from_input(tool_input)})" + if tool_name in ("ask_user", "mcp__bird-interact-tools__ask_user"): + question = tool_input.get("question", _compact(tool_input)) + return f"ask({question})" + + canonical = CANONICAL_NAME_MAP.get(tool_name) + if canonical is None: + return f"{tool_name}({_compact(tool_input)})" + if not tool_input: + return f"{canonical}()" + return f"{canonical}({_compact(tool_input)})" + + +def action_args_string(tool_name: str, tool_input: dict[str, Any]) -> str: + """The string that ``count_tokens`` measures for ``action_input_tokens``. + + Per the spec: SQL string for submit/execute; question string for + ask; ``compact json.dumps`` for everything else. + """ + if tool_name == "mcp__bird-interact-tools__submit_query": + return _sql_from_input(tool_input) + if tool_name == "mcp__bird-interact-tools__execute_sql": + return _sql_from_input(tool_input) + if tool_name in ("ask_user", "mcp__bird-interact-tools__ask_user"): + return str(tool_input.get("question", _compact(tool_input))) + return _compact(tool_input) diff --git a/src/bird_interact_agents/reports/adapters/__init__.py b/src/bird_interact_agents/reports/adapters/__init__.py new file mode 100644 index 00000000..f6132551 --- /dev/null +++ b/src/bird_interact_agents/reports/adapters/__init__.py @@ -0,0 +1,67 @@ +"""Per-framework trajectory→Turn adapters. + +Real cloud runs persist ``framework="claude_sdk"`` in ``results.db. +run_metadata`` (that's the CLI flag of ``bird-interact-cloud submit``); +the SLayer-vs-raw / one-shot-vs-a-interact distinction lives in the +``query_mode`` and ``mode`` columns. The submission report is supported +only on the ``(framework="claude_sdk", query_mode="slayer")`` combo: + +* ``query_mode="slayer"``: Anthropic SDK messages are persisted as + nested dicts (``data`` is a dict with ``content``, ``model``, …) and + the shared dict-walker handles them. +* ``query_mode="raw"``: trajectory entries' ``data`` field is a + Python-repr STRING; needs a separate string-repr parser that isn't + written yet. + +We also accept ``framework="claude_sdk_otf"`` / +``"claude_sdk_otf_ainteract"`` for forward-compatibility with any future +run-metadata schema that promotes the internal agent name to a +persisted field — both share the same dict walker. + +Other frameworks (pydantic_ai*, smolagents, agno, mcp_agent) are out of +scope for DEV-1553. +""" + +from __future__ import annotations + +from typing import Callable, Iterable + +from bird_interact_agents.reports.adapters.base import Turn +from bird_interact_agents.reports.adapters.claude_sdk_otf import ( + walk_trajectory as _claude_sdk_otf_walk, +) + + +# Keyed by (framework, query_mode). All entries point at the dict-walker; +# the only thing that varies is which combinations we accept. +_REGISTRY: dict[tuple[str, str], Callable[[list[dict]], Iterable[Turn]]] = { + ("claude_sdk", "slayer"): _claude_sdk_otf_walk, + ("claude_sdk_otf", "slayer"): _claude_sdk_otf_walk, + ("claude_sdk_otf_ainteract", "slayer"): _claude_sdk_otf_walk, +} + + +class UnknownFrameworkError(ValueError): + pass + + +def get_adapter( + framework: str, *, query_mode: str = "slayer" +) -> Callable[[list[dict]], Iterable[Turn]]: + """Resolve ``(framework, query_mode)`` to its trajectory walker. + + Raises ``UnknownFrameworkError`` for any unsupported combo — the + error message spells out the supported list so the failure surfaces + with actionable guidance, not a mid-walk AttributeError. + """ + try: + return _REGISTRY[(framework, query_mode)] + except KeyError: + raise UnknownFrameworkError( + f"no submission-report adapter registered for " + f"(framework={framework!r}, query_mode={query_mode!r}); " + f"supported: {sorted(_REGISTRY)}" + ) + + +__all__ = ["Turn", "get_adapter", "UnknownFrameworkError"] diff --git a/src/bird_interact_agents/reports/adapters/base.py b/src/bird_interact_agents/reports/adapters/base.py new file mode 100644 index 00000000..40e0585d --- /dev/null +++ b/src/bird_interact_agents/reports/adapters/base.py @@ -0,0 +1,38 @@ +"""Shared ``Turn`` dataclass for trajectory adapters.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class Turn(BaseModel): + """One agent assistant-message + its emitted action + the resulting + observation. Framework-agnostic intermediate representation. + """ + + # Agent model used for this turn (e.g. ``claude-opus-4-7``). Most + # runs hold this constant across turns, but we carry it per-turn so + # multi-model trajectories work the day someone files one. + model: str + + # The exact text the agent saw to produce this turn. For turn 0 this + # is the initial task statement; for later turns it's the + # concatenation of every tool_result + free UserMessage text seen + # between the previous tool_use and this one. + prompt: str + + # Rendered raw assistant message. Carries thinking + text + tool_use + # JSON when include_thinking=True; thinking stripped otherwise. + response_raw: str + + # Tool call. + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + + # Tool result text (collapsed into a single string). + observation: str + + model_config = {"arbitrary_types_allowed": True} diff --git a/src/bird_interact_agents/reports/adapters/claude_sdk_otf.py b/src/bird_interact_agents/reports/adapters/claude_sdk_otf.py new file mode 100644 index 00000000..0da74362 --- /dev/null +++ b/src/bird_interact_agents/reports/adapters/claude_sdk_otf.py @@ -0,0 +1,171 @@ +"""Claude Agent SDK trajectory walker (claude_sdk_otf_* family). + +Consumes the SDK-native message stream +(``SystemMessage`` / ``AssistantMessage`` / ``UserMessage`` / +``ResultMessage``) and yields one ``Turn`` per ``tool_use`` block. Pure- +text / thinking assistant messages without tool_use fold into the NEXT +tool-using turn so the leaderboard never sees no-op rows. +""" + +from __future__ import annotations + +import json +from typing import Any, Iterable + +from bird_interact_agents.reports.adapters.base import Turn + + +def _normalize_content_to_text(content: Any) -> str: + """Collapse mixed Anthropic-SDK content shapes into a single string.""" + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + chunks: list[str] = [] + for item in content: + if isinstance(item, dict): + if "text" in item: + chunks.append(str(item["text"])) + else: + chunks.append(json.dumps(item, separators=(",", ":"))) + else: + chunks.append(str(item)) + return "\n".join(chunks) + if isinstance(content, dict): + return json.dumps(content, separators=(",", ":")) + return str(content) + + +def _render_response( + pending_thinking: str, + pending_text: str, + tool_use: dict[str, Any], +) -> str: + """Render the assistant message contents as a JSON list of content + items. Tests can strip ``"thinking"``-typed items from this JSON + string at output time for the ``--no-thinking`` flag — the literal + token ``"thinking"`` therefore appears iff a thinking block survives. + """ + items: list[dict[str, Any]] = [] + if pending_thinking: + items.append({"type": "thinking", "thinking": pending_thinking}) + if pending_text: + items.append({"type": "text", "text": pending_text}) + items.append( + { + "type": "tool_use", + "id": tool_use.get("id", ""), + "name": tool_use.get("name", ""), + "input": tool_use.get("input", {}), + } + ) + return json.dumps(items, separators=(",", ":")) + + +def _is_tool_use(block: dict[str, Any]) -> bool: + return block.get("type") == "tool_use" or ( + "name" in block and "input" in block and "id" in block + ) + + +def walk_trajectory(steps: list[dict[str, Any]]) -> Iterable[Turn]: + """Yield one ``Turn`` per assistant tool_use block. + + Pass 1 builds the ``tool_use_id -> tool_result_text`` map; pass 2 + walks the trajectory in order, accumulating prompt/thinking/text + buffers and emitting a Turn at every tool_use. + """ + # Pass 1: build the tool_use_id → observation map. tool_result + # content can arrive as a string, a list of text blocks, or a bare + # list of strings — _normalize_content_to_text handles all three. + tool_result_by_id: dict[str, str] = {} + for msg in steps: + if msg.get("type") != "UserMessage": + continue + data = msg.get("data") or {} + for c in data.get("content") or []: + if isinstance(c, dict) and c.get("type") == "tool_result": + tool_result_by_id[c.get("tool_use_id", "")] = ( + _normalize_content_to_text(c.get("content")) + ) + + # Pass 2. + prompt_buf: list[str] = [] + pending_thinking = "" + pending_text = "" + last_model = "" + + def _append_prompt(s: str) -> None: + if not s: + return + if prompt_buf and not prompt_buf[-1].endswith("\n"): + prompt_buf.append("\n") + prompt_buf.append(s) + + for msg in steps: + t = msg.get("type") + data = msg.get("data") or {} + if t == "UserMessage": + for c in data.get("content") or []: + if not isinstance(c, dict): + _append_prompt(str(c)) + continue + kind = c.get("type") + if kind == "tool_result": + _append_prompt( + _normalize_content_to_text(c.get("content")) + ) + elif kind == "text" or "text" in c: + _append_prompt(str(c.get("text", ""))) + elif t == "AssistantMessage": + model = data.get("model") or last_model + last_model = model or last_model + # Codex round 7: an assistant message can carry MULTIPLE + # tool_use blocks. Collect everything first, then emit one + # Turn per tool_use sharing the same prompt + thinking + + # text context (the model produced them all from the same + # message). Resetting state mid-message would leave the 2nd+ + # tool calls with an empty prompt and missing thinking/text. + msg_thinking_chunks: list[str] = [] + msg_text_chunks: list[str] = [] + tool_uses_in_msg: list[dict] = [] + for c in data.get("content") or []: + if not isinstance(c, dict): + continue + if _is_tool_use(c): + tool_uses_in_msg.append(c) + elif "thinking" in c: + msg_thinking_chunks.append(str(c.get("thinking") or "")) + elif "text" in c: + msg_text_chunks.append(str(c.get("text") or "")) + + if not tool_uses_in_msg: + # Pure-text / thinking-only message — fold into the next + # tool-using turn (no Turn emitted now). + pending_thinking += "".join(msg_thinking_chunks) + pending_text += "".join(msg_text_chunks) + continue + + full_thinking = pending_thinking + "".join(msg_thinking_chunks) + full_text = pending_text + "".join(msg_text_chunks) + shared_prompt = "".join(prompt_buf).rstrip() + + for c in tool_uses_in_msg: + tu_id = str(c.get("id", "")) + observation = tool_result_by_id.get(tu_id, "") + yield Turn( + model=last_model, + prompt=shared_prompt, + response_raw=_render_response(full_thinking, full_text, c), + tool_name=str(c.get("name", "")), + tool_input=dict(c.get("input") or {}), + tool_use_id=tu_id, + observation=observation, + ) + # Reset context AFTER the whole message (not after each + # tool_use within it). + prompt_buf = [] + pending_thinking = "" + pending_text = "" + # SystemMessage / ResultMessage / other → skip silently. diff --git a/src/bird_interact_agents/reports/budget.py b/src/bird_interact_agents/reports/budget.py new file mode 100644 index 00000000..edf54be9 --- /dev/null +++ b/src/bird_interact_agents/reports/budget.py @@ -0,0 +1,78 @@ +"""Budget calculation + replay. + +* ``calculate_total_budget(task_data, patience)`` mirrors + ``harness.calculate_budget(task_data, patience, mode="a-interact")`` = + ``6 + 2*ambiguity_count + 2*patience``. We re-implement here so the + reports package has no runtime dependency on the harness import chain + (which pulls in heavy adapters); the parity is pinned by tests. +* ``replay_remaining_budget(total, costs)`` returns the per-step + ``remaining_budget`` (clipped at 0). +* ``lookup_task_data(benchmark, instance_id)`` joins the benchmark's + task data file on ``instance_id``. +""" + +from __future__ import annotations + + +def _ambiguity_count(task_data: dict) -> int: + n = 0 + user_query_ambiguity = task_data.get("user_query_ambiguity", {}) or {} + if "critical_ambiguity" in user_query_ambiguity: + n += len(user_query_ambiguity["critical_ambiguity"]) + kb_amb = task_data.get("knowledge_ambiguity") or [] + n += len(kb_amb) + return n + + +def calculate_total_budget(task_data: dict, *, patience: int) -> float: + return 6.0 + 2.0 * _ambiguity_count(task_data) + 2.0 * patience + + +def replay_remaining_budget( + *, total_budget: float, action_costs: list[float] +) -> list[float]: + cum = 0.0 + out: list[float] = [] + for c in action_costs: + cum += c + out.append(max(0.0, total_budget - cum)) + return out + + +# --------------------------------------------------------------------------- +# task_data lookup +# --------------------------------------------------------------------------- + +# Cache by (benchmark, instance_id) so a 270-instance run doesn't re-parse +# the gold JSONL 270 times. +_TASK_DATA_CACHE: dict[tuple[str, str], dict] = {} + + +def lookup_task_data(benchmark: str, instance_id: str) -> dict: + """Load the benchmark's task data file and return the row whose + ``instance_id`` matches. Raises ``KeyError`` when not found. + + Uses ``bird_interact_agents.benchmark`` resolution; data lives at + ``paths.benchmark_data_file(benchmark)``. + """ + key = (benchmark, instance_id) + if key in _TASK_DATA_CACHE: + return _TASK_DATA_CACHE[key] + + import json + + from bird_interact_agents import paths + + path = paths.benchmark_data_file(benchmark) + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + row = json.loads(line) + if row.get("instance_id") == instance_id: + _TASK_DATA_CACHE[key] = row + return row + raise KeyError( + f"instance_id={instance_id!r} not found in {path} (benchmark={benchmark!r})" + ) diff --git a/src/bird_interact_agents/reports/cli.py b/src/bird_interact_agents/reports/cli.py new file mode 100644 index 00000000..42853662 --- /dev/null +++ b/src/bird_interact_agents/reports/cli.py @@ -0,0 +1,412 @@ +"""``bird-interact-cloud submission`` subcommand entry point.""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import hashlib +import json +import re +import sys +from pathlib import Path + +from bird_interact_agents import paths +from bird_interact_agents.reports import coverage as _coverage +from bird_interact_agents.reports import budget as _budget +from bird_interact_agents.reports.converter import ( + build_submission_row, + cross_check_results_db_sql, +) +from bird_interact_agents.reports.leakage import count_leakage +from bird_interact_agents.reports.output import ( + ManifestPlan, + write_submission, +) +from bird_interact_agents.reports.selection import ( + DuplicateInstanceError, + load_selection, +) +from bird_interact_agents.reports.sources import ( + DuplicateTaskResultsError, + MissingTaskResultsError, + MissingTrajectoryError, + StubTrajectoryError, + resolve_sources, +) + + +_SUPPORTED_BENCHMARKS = ("bird-interact-lite-exp", "bird-interact-full", "mini-interact") +_BENCHMARK_TO_SPLIT = { + "bird-interact-lite-exp": "lite", + "bird-interact-full": "full", + "mini-interact": "mini-interact", +} + + +def _slugify(s: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") + + +def _selection_tag(selection_entries: list[tuple[str, str]]) -> str: + canonical = json.dumps( + sorted(selection_entries), separators=(",", ":") + ).encode() + return "selection-" + hashlib.sha256(canonical).hexdigest()[:10] + + +def _read_patience_for_instance( + instance_dir: Path, run_id: str +) -> tuple[int | None, str]: + """Look for a ``patience`` field in the per-instance submission- + annotation sidecar. Returns (patience, source). Most cloud runs DO + NOT carry patience here — it's a run-level setting, see + ``_read_patience_for_run``. Kept for forward-compat with any harness + that stamps patience per-instance.""" + p = instance_dir / f"{run_id}.json" + if not p.is_file(): + return (None, "default") + try: + obj = json.loads(p.read_text()) + except (json.JSONDecodeError, OSError): + return (None, "default") + pat = obj.get("patience") + if pat is None and isinstance(obj.get("submission"), dict): + pat = obj["submission"].get("patience") + if pat is None: + return (None, "default") + try: + return (int(pat), f"runs:{p.name}") + except (ValueError, TypeError): + return (None, "default") + + +def _read_patience_for_run( + *, benchmark: str, run_id: str +) -> tuple[int | None, str]: + """Look up the run-level cloud manifest's ``patience`` field. + + The cloud `bird-interact-cloud submit` driver writes its rendered + cluster config (including ``--patience`` from the CLI) to + ``//cloud//manifest.json`` (with a + legacy path under ``/cloud//manifest.json`` for + pre-DEV-1462 runs). Both are tried in order; the first match wins. + """ + candidates = [ + paths.results_root() / benchmark / "cloud" / run_id / "manifest.json", + paths.results_root() / "cloud" / run_id / "manifest.json", + ] + for p in candidates: + if not p.is_file(): + continue + try: + obj = json.loads(p.read_text()) + except (json.JSONDecodeError, OSError): + continue + pat = obj.get("patience") + if pat is None: + continue + try: + return (int(pat), f"results:{p.relative_to(paths.results_root())}") + except (ValueError, TypeError): + continue + return (None, "default") + + +def add_subparser(subparsers: argparse._SubParsersAction) -> None: + sp = subparsers.add_parser( + "submission", + help="Generate a BIRD-INTERACT-1.0 a-Interact submission directory.", + ) + sp.add_argument("--team-name", required=True) + sp.add_argument("--method-name", required=True) + sp.add_argument( + "--benchmark", + required=True, + choices=_SUPPORTED_BENCHMARKS, + ) + sel = sp.add_mutually_exclusive_group(required=True) + sel.add_argument("--run-id") + sel.add_argument("--selection", type=Path) + sp.add_argument("--allow-partial", action="store_true") + sp.add_argument( + "--patience", + type=int, + default=3, + help="Fallback patience value when the per-instance json lacks one.", + ) + sp.add_argument("--report-tag") + sp.add_argument("--out", type=Path) + sp.add_argument( + "--no-thinking", + dest="include_thinking", + action="store_false", + default=True, + help="Strip thinking blocks from the `response` field of every step.", + ) + sp.add_argument( + "--check-leakage", + action="store_true", + help="Add per-instance gold-SQL substring count to manifest.leakage_check.", + ) + + +def run_submission(args: argparse.Namespace) -> int: + benchmark: str = args.benchmark + setting = "a-Interact" + split = _BENCHMARK_TO_SPLIT[benchmark] + + # ---- Selection ------------------------------------------------ + if args.selection: + # Codex round 8: parse errors from load_selection (duplicate + # instance_id, malformed JSON, missing field, OSError) must + # produce the same SystemExit(2) path as downstream resolve / + # coverage failures — not an uncaught traceback. + try: + selection = load_selection(args.selection) + except ( + DuplicateInstanceError, + KeyError, + json.JSONDecodeError, + OSError, + ) as e: + sys.stderr.write(f"error: {e}\n") + raise SystemExit(2) from e + selection_mode = "selection-file" + source_run_ids = sorted({rid for _, rid in selection}) + default_tag = _selection_tag(selection) + else: + # --run-id path: list every instance in that run's results.db. + results_db = ( + paths.results_root() + / benchmark + / "cloud" + / args.run_id + / "results.db" + ) + if not results_db.is_file(): + sys.stderr.write( + f"error: results.db not found at {results_db}\n" + ) + return 2 + import sqlite3 + + con = sqlite3.connect(results_db) + con.row_factory = sqlite3.Row + try: + iids = [ + row["instance_id"] + for row in con.execute( + "SELECT instance_id FROM task_results WHERE run_id = ?", + (args.run_id,), + ) + ] + finally: + con.close() + selection = [(iid, args.run_id) for iid in iids] + selection_mode = "run-id" + source_run_ids = [args.run_id] + default_tag = args.run_id + + tag = args.report_tag or default_tag + + # Codex round 8: refuse a zero-instance submission. An empty + # selection file, or a `--run-id` whose task_results has no rows, + # would otherwise (with --allow-partial) produce a valid-looking + # but empty submission.jsonl + manifest.n_instances=0. That's never + # a useful artifact; abort before resolve_sources to give the + # operator a clear error. + if not selection: + sys.stderr.write( + "error: selection resolved to zero instances — nothing to " + "submit. Check `--selection ` is non-empty, or that " + "the `--run-id` you passed actually wrote task_results rows.\n" + ) + raise SystemExit(2) + + # ---- Resolve sources (errors out on missing/stub trajectories + # and on selection entries lacking a task_results row) + try: + sources = resolve_sources(selection=selection, benchmark=benchmark) + except ( + MissingTrajectoryError, + StubTrajectoryError, + MissingTaskResultsError, + DuplicateTaskResultsError, + FileNotFoundError, + ) as e: + sys.stderr.write(f"error: {e}\n") + raise SystemExit(2) from e + + # ---- Setting gate: a-Interact only (per DEV-1553 spec). The mode + # comes from the source run's task_results row; any non-a-interact + # row indicates the operator pointed the converter at a c-interact / + # one-shot / oracle run by mistake. Codex round 6: an EMPTY mode + # also counts as invalid (defense-in-depth; round 3's + # MissingTaskResultsError catches the obvious case, but a row whose + # `mode` column is null/empty would otherwise sneak past). + wrong_mode = sorted( + f"{iid}({src.mode!r})" + for iid, src in sources.items() + if src.mode != "a-interact" + ) + if wrong_mode: + sys.stderr.write( + "error: a-Interact submission report requires " + f"mode='a-interact' on every selected instance; got " + f"{', '.join(wrong_mode)}\n" + ) + raise SystemExit(2) + + # Codex round 7: same defensive gate on query_mode. Adapter registry + # only accepts ("claude_sdk", "slayer"); without the explicit gate + # here, an empty `query_mode` silently defaults to "slayer" at the + # converter call site, and a truthy unsupported value like "raw" + # would crash inside `build_submission_row` with an uncaught + # `UnknownFrameworkError` traceback. List every offender. + wrong_qm = sorted( + f"{iid}({src.query_mode!r})" + for iid, src in sources.items() + if src.query_mode != "slayer" + ) + if wrong_qm: + sys.stderr.write( + "error: a-Interact submission report requires " + f"query_mode='slayer' on every selected instance " + f"(raw-mode trajectories persist `data` as Python-repr " + f"strings and need a separate parser); got " + f"{', '.join(wrong_qm)}\n" + ) + raise SystemExit(2) + + # ---- Coverage check ------------------------------------------ + try: + _coverage.assert_coverage_ok( + benchmark=benchmark, + present_instance_ids=set(sources.keys()), + allow_partial=args.allow_partial, + ) + except ( + _coverage.IncompleteCoverageError, + _coverage.UnknownInstanceError, + ) as e: + sys.stderr.write(f"error: {e}\n") + raise SystemExit(2) from e + + # ---- Build rows --------------------------------------------- + rows = [] + instance_manifest_entries: list[dict] = [] + patience_resolution: list[dict] = [] + warnings_by_instance: list[dict] = [] + leakage_entries: list[dict] = [] + + # Patience resolution is per-instance but most cloud runs only stamp + # patience at the RUN level. Cache the run-level lookup so we don't + # re-read the manifest per instance. + run_level_patience: dict[str, tuple[int | None, str]] = {} + + for inst_id, src in sources.items(): + instance_dir = src.trajectory_path.parent + per_inst_patience, per_inst_source = _read_patience_for_instance( + instance_dir, src.run_id + ) + if per_inst_patience is None: + if src.run_id not in run_level_patience: + run_level_patience[src.run_id] = _read_patience_for_run( + benchmark=benchmark, run_id=src.run_id + ) + run_pat, run_source = run_level_patience[src.run_id] + else: + run_pat, run_source = (None, "default") + + if per_inst_patience is not None: + patience, source_label = per_inst_patience, per_inst_source + elif run_pat is not None: + patience, source_label = run_pat, run_source + else: + patience, source_label = args.patience, "default" + patience_resolution.append( + {"instance_id": inst_id, "patience": patience, "source": source_label} + ) + + task_data = _budget.lookup_task_data(benchmark, inst_id) + row, converter_warnings = build_submission_row( + trajectory_obj=src.trajectory_obj, + framework=src.framework, + agent_model=src.agent_model, + user_sim_model=src.user_sim_model, + task_data=task_data, + patience=patience, + include_thinking=args.include_thinking, + query_mode=src.query_mode or "slayer", + instance_id=inst_id, + ) + rows.append(row) + + instance_manifest_entries.append( + { + "instance_id": inst_id, + "run_id": src.run_id, + "framework": src.framework, + "agent_model": src.agent_model, + "user_sim_model": src.user_sim_model, + "trajectory_path": str(src.trajectory_path), + "results_db_path": str(src.results_db_path), + "phase1_passed": bool(src.task_results_row.get("phase1_passed")), + "phase2_passed": bool(src.task_results_row.get("phase2_passed")), + "error": src.task_results_row.get("error"), + } + ) + + # Cross-check warning vs results.db.submitted_sql (last submit only) + # PLUS converter warnings (phase-split: missing/inconsistent markers). + db_sql = src.task_results_row.get("submitted_sql") or "" + warns = list(converter_warnings) + cross_check_results_db_sql( + row=row, results_db_submitted_sql=db_sql + ) + if warns: + warnings_by_instance.append({"instance_id": inst_id, "warnings": warns}) + + # --check-leakage (optional) + if args.check_leakage: + prompts = [e.prompt for e in row.prompt_flow] + gold = src.task_results_row.get("ground_truth_sql") or src.trajectory_obj.get( + "ground_truth_sql" + ) + n = count_leakage(prompts=prompts, ground_truth_sql=gold) + leakage_entries.append({"instance_id": inst_id, "leak_count": n}) + + # ---- Pick output directory ---------------------------------- + team_slug = _slugify(args.team_name) + method_slug = _slugify(args.method_name) + if args.out: + out_dir = Path(args.out) + else: + out_dir = ( + paths.reports_root() + / benchmark + / setting + / f"{team_slug}__{method_slug}__{tag}" + ) + + leakage_block = ( + {"min_substring": 12, "per_instance": leakage_entries} + if args.check_leakage + else None + ) + plan = ManifestPlan( + benchmark=benchmark, + setting=setting, + split=split, + team=args.team_name, + method=args.method_name, + tag=tag, + selection_mode=selection_mode, + source_run_ids=source_run_ids, + generated_at=_dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"), + instances=instance_manifest_entries, + patience_resolution=patience_resolution, + leakage_check=leakage_block, + warnings_by_instance=warnings_by_instance, + ) + write_submission(rows=rows, plan=plan, out_dir=out_dir) + return 0 diff --git a/src/bird_interact_agents/reports/converter.py b/src/bird_interact_agents/reports/converter.py new file mode 100644 index 00000000..3a2b8709 --- /dev/null +++ b/src/bird_interact_agents/reports/converter.py @@ -0,0 +1,271 @@ +"""Trajectory + task-data → ``SubmissionRow``. + +Top-level entry point: ``build_submission_row``. + +* Walks the trajectory via the framework's registered adapter. +* Canonicalises each tool_use to upstream action names. +* Counts tokens for ``action_input_tokens`` / ``action_output_tokens``. +* Replays Section VI costs to compute per-step ``action_cost`` and + cumulative ``remaining_budget``. +* Splits submits into phase-1 vs phase-2 using ``phase_split``. +* Stitches the final SQL per phase into ``subtask_K_predicted_sql``. +* Strips thinking blocks from each step's ``response`` when + ``include_thinking=False``. +""" + +from __future__ import annotations + +import json +import re + +from bird_interact_agents.reports.action_canonicalize import ( + action_args_string, + canonicalize_action, +) +from bird_interact_agents.reports.adapters import get_adapter +from bird_interact_agents.reports.budget import ( + calculate_total_budget, + replay_remaining_budget, +) +from bird_interact_agents.reports.cost import compute_action_cost +from bird_interact_agents.reports.phase_split import split_phases, SplitResult +from bird_interact_agents.reports.schema import PromptFlowEntry, SubmissionRow +from bird_interact_agents.reports import tokens as _tokens + + +def _strip_thinking(response_raw: str) -> str: + """Remove every ``{"type":"thinking", …}`` content item from the + JSON-encoded ``response_raw``. Falls back to a no-op if the response + isn't JSON-encoded for some reason.""" + try: + items = json.loads(response_raw) + except (json.JSONDecodeError, TypeError): + return response_raw + if not isinstance(items, list): + return response_raw + kept = [i for i in items if not (isinstance(i, dict) and i.get("type") == "thinking")] + return json.dumps(kept, separators=(",", ":")) + + +def build_submission_row( + *, + trajectory_obj: dict, + framework: str, + agent_model: str, + user_sim_model: str, + task_data: dict, + patience: int, + include_thinking: bool = True, + query_mode: str = "slayer", + instance_id: str | None = None, +) -> tuple[SubmissionRow, list[str]]: + """Build the SubmissionRow PLUS the list of converter warnings + (phase-split warnings, etc.) that the CLI surfaces in + ``manifest.warnings_by_instance``. + + Codex round 9: when ``instance_id`` is supplied it is the authoritative + id (from the selection / results.db row); the trajectory's stamped id is + validated against it. A mismatch indicates a stale / mis-copied + trajectory file and adds a manifest warning. When ``instance_id`` is + None the trajectory's stamped id is used as a fallback (kept for + legacy callers / unit tests that don't pre-thread the trusted id). + """ + walk = get_adapter(framework, query_mode=query_mode) + steps = trajectory_obj.get("trajectory") or [] + turns = list(walk(steps)) + + # ---- Canonicalize + token counts + Section VI cost -------------- + canonical_actions: list[str] = [] + action_in_toks: list[int] = [] + action_out_toks: list[int] = [] + action_costs: list[float] = [] + for t in turns: + canonical = canonicalize_action(t.tool_name, t.tool_input) + # canonical_op = "ask" / "submit" / "execute" / . + # The cost classifier reads the leading token. + op = canonical.split("(", 1)[0] + args_str = action_args_string(t.tool_name, t.tool_input) + in_tokens = _tokens.count_tokens(args_str) if args_str else 0 + out_tokens = ( + _tokens.count_tokens(t.observation) if t.observation else 0 + ) + cost = compute_action_cost( + op, input_tokens=in_tokens, output_tokens=out_tokens + ) + canonical_actions.append(canonical) + action_in_toks.append(in_tokens) + action_out_toks.append(out_tokens) + action_costs.append(cost) + + # ---- Budget replay ---------------------------------------------- + total_budget = calculate_total_budget(task_data, patience=patience) + remaining = replay_remaining_budget( + total_budget=total_budget, action_costs=action_costs + ) + + # ---- Phase split on the submit subset --------------------------- + submit_idxs = [ + i for i, a in enumerate(canonical_actions) if a.startswith("submit(") + ] + submit_observations = [turns[i].observation for i in submit_idxs] + phase_result: SplitResult = split_phases(submit_observations) + extra_warnings: list[str] = [] + + # The compiled SQL the leaderboard grades comes from one of three + # places depending on query_mode: + # * ``raw``: the agent literally passed SQL in ``query`` / + # ``query_json``; we pull from ``turn.tool_input`` directly so we + # don't depend on the canonical-string slice (which keeps any + # wrapping the canonicalizer added). + # * ``slayer``: the agent passed a SlayerQuery JSON DSL; the SERVER + # compiles it to SQL. The compiled SQL is embedded in EVERY + # submit's tool_result with the prefix ``Generated SQL:\n\n + # \nResult: `` (see _submit.py:806). Parsing the observation lets + # us recover per-phase compiled SQL — not just the final one + # (which is the only one ``trajectory.submitted_sql`` persists). + # * Fallback for SLayer: if the observation has no ``Generated SQL`` + # marker (legacy / unexpected shape), use ``trajectory.submitted + # _sql`` for the LAST submit only. + trajectory_final_sql = str(trajectory_obj.get("submitted_sql") or "") + _GENERATED_SQL_RE = re.compile( + r"Generated SQL:\s*\n(?P.*?)(?=\n\nResult:|\Z)", re.DOTALL + ) + + def _extract_compiled_sql_from_observation(obs: str) -> str: + m = _GENERATED_SQL_RE.search(obs or "") + return m.group("sql").strip() if m else "" + + def _looks_like_json_dsl(s: str) -> bool: + """SLayer's ``submit_query.query_json`` accepts either a single + SlayerQuery object (``{...}``) OR a nested-DAG array of stage + objects (``[...]`` — see claude_sdk/agent.py:374). Both must + route through the compiled-SQL extraction path; treating ``[`` + as raw SQL would silently emit JSON DSL where SQL is expected + (Codex round 5 finding).""" + head = s.strip() + return head.startswith("{") or head.startswith("[") + + def _raw_input_sql(turn) -> str: + for key in ("query", "query_json", "sql"): + if key in turn.tool_input: + return str(turn.tool_input[key]) + return "" + + last_phase1_sql = "" + last_phase2_sql = "" + have_phase1 = False + have_phase2 = False + last_submit_idx = submit_idxs[-1] if submit_idxs else None + + for k, idx in enumerate(submit_idxs): + label = phase_result.labels[k] if k < len(phase_result.labels) else None + turn = turns[idx] + raw_input = _raw_input_sql(turn) + if _looks_like_json_dsl(raw_input): + # SLayer DSL. Try the per-submit `Generated SQL:` prefix in + # the observation first (real harness emits it for every + # submit, recoverable per-phase). Fall back to the + # trajectory's last `submitted_sql` if the observation has + # no marker (legacy / smoke fixtures). + sql = _extract_compiled_sql_from_observation(turn.observation) + if not sql: + if idx == last_submit_idx and trajectory_final_sql: + sql = trajectory_final_sql + else: + sql = "" + else: + sql = raw_input + + if label == "phase2": + last_phase2_sql = sql + have_phase2 = True + else: + last_phase1_sql = sql + have_phase1 = True + + # Manifest warning when an earlier phase's compiled SQL still + # couldn't be recovered (observation lacked the marker AND wasn't + # the trajectory's final submit). Real harness runs hit the + # observation-marker path; only legacy / malformed trajectories + # should fall through to this warning. + if have_phase1 and have_phase2 and not last_phase1_sql: + extra_warnings.append( + "SLayer-mode phase-1 compiled SQL could not be recovered from " + "the trajectory: neither the submit observation carried a " + "`Generated SQL:` prefix nor was this the trajectory's final " + "submit. Emitting empty subtask_1_predicted_sql; review the " + "trajectory manually." + ) + + subtask_1_predicted_sql = [last_phase1_sql] if have_phase1 else [] + subtask_2_predicted_sql = [last_phase2_sql] if have_phase2 else [] + + # ---- Build prompt_flow ------------------------------------------ + entries: list[PromptFlowEntry] = [] + for k, t in enumerate(turns): + response = ( + t.response_raw if include_thinking else _strip_thinking(t.response_raw) + ) + entries.append( + PromptFlowEntry( + model=agent_model, + user_simulator=user_sim_model, + prompt=t.prompt, + response=response, + action=canonical_actions[k], + remaining_budget=remaining[k] if k < len(remaining) else total_budget, + action_input_tokens=action_in_toks[k], + action_output_tokens=action_out_toks[k], + action_cost=action_costs[k], + ) + ) + + payload_instance_id = str(trajectory_obj.get("instance_id") or "") + if instance_id is not None: + effective_instance_id = instance_id + if payload_instance_id and payload_instance_id != instance_id: + extra_warnings.append( + f"trajectory.instance_id={payload_instance_id!r} mismatches " + f"the source-resolved instance_id={instance_id!r}; using the " + "source-resolved id. Check for a stale or mis-copied " + "trajectory file." + ) + else: + effective_instance_id = payload_instance_id + + row = SubmissionRow( + instance_id=effective_instance_id, + subtask_1_predicted_sql=subtask_1_predicted_sql, + subtask_2_predicted_sql=subtask_2_predicted_sql, + prompt_flow=entries, + ) + return row, list(phase_result.warnings) + extra_warnings + + +def cross_check_results_db_sql( + *, row: SubmissionRow, results_db_submitted_sql: str +) -> list[str]: + """Compare ``results.db.task_results.submitted_sql`` to the LAST phase's + final SQL. Mismatch → return a warning string list (never raises). + + Codex finding #6: the DB only stores ONE final SQL, so the cross-check + is necessarily last-submit only. Earlier phase-1 retries that differ + from the DB are NOT flagged. + """ + if row.subtask_2_predicted_sql: + last = row.subtask_2_predicted_sql[0] + phase = "2" + elif row.subtask_1_predicted_sql: + last = row.subtask_1_predicted_sql[0] + phase = "1" + else: + # Nothing to cross-check. + return [] + + if last == results_db_submitted_sql: + return [] + return [ + f"results.db.submitted_sql mismatches phase-{phase} reconstructed SQL " + f"(instance_id={row.instance_id}): DB stored {results_db_submitted_sql!r}, " + f"trajectory ended on {last!r}" + ] diff --git a/src/bird_interact_agents/reports/cost.py b/src/bird_interact_agents/reports/cost.py new file mode 100644 index 00000000..47d77ffc --- /dev/null +++ b/src/bird_interact_agents/reports/cost.py @@ -0,0 +1,54 @@ +"""Section VI Universal Cost Scheme. + +This is the LEADERBOARD contract for a-Interact custom agents — NOT our +internal ``harness.ACTION_COSTS`` table. The harness assigns its own +costs at run-time (per ``ACTION_COSTS`` in ``harness.py``) but for the +report we MUST replay using Section VI rules: + +* ``ask = 2``, ``submit = 3``, ``execute = 1`` (fixed; token counts + ignored for these). +* Every other action is token-aware: ``input < 250 AND output < 1000`` + → ``0.5``; else ``1.0`` (Section VI prose example: a getter that + produces a 400-token observation off a 4-token call → 0.5; same + getter that produces a 2500-token observation → 1.0). + +Canonical names ``ask`` / ``submit`` / ``execute`` come from upstream's +``eval_react_bird_interact.py``; the Section VI prose uses +``ask_user`` / ``submit_sql`` / ``execute_sql``. ``action_canonicalize`` +defines the mapping; we accept the short forms here. +""" + +from __future__ import annotations + + +FIXED_COSTS: dict[str, int] = {"ask": 2, "submit": 3, "execute": 1} + +SECTION_VI_THRESHOLDS: dict[str, float] = { + "input_tokens_lt": 250, + "output_tokens_lt": 1000, + "cheap_cost": 0.5, + "expensive_cost": 1.0, +} + + +def compute_action_cost( + canonical_action: str, + *, + input_tokens: int, + output_tokens: int, +) -> float: + """Return the Section VI cost for one action call. + + ``canonical_action`` is the upstream-canonical form returned by + ``action_canonicalize.canonicalize_action`` — i.e. ``ask`` / + ``submit`` / ``execute`` for the fixed-cost trio, anything else for + token-aware actions. + """ + if canonical_action in FIXED_COSTS: + return float(FIXED_COSTS[canonical_action]) + if ( + input_tokens < SECTION_VI_THRESHOLDS["input_tokens_lt"] + and output_tokens < SECTION_VI_THRESHOLDS["output_tokens_lt"] + ): + return SECTION_VI_THRESHOLDS["cheap_cost"] + return SECTION_VI_THRESHOLDS["expensive_cost"] diff --git a/src/bird_interact_agents/reports/coverage.py b/src/bird_interact_agents/reports/coverage.py new file mode 100644 index 00000000..04d05e7e --- /dev/null +++ b/src/bird_interact_agents/reports/coverage.py @@ -0,0 +1,65 @@ +"""Split-coverage check. + +Without ``--allow-partial``, the present instance set MUST equal the +benchmark's full instance set. Extra instances (typo'd instance_ids +that aren't in the benchmark) are always a hard error — they signal a +selection-file mistake, not a partial run. +""" + +from __future__ import annotations + +import json + +from bird_interact_agents import paths + + +class IncompleteCoverageError(ValueError): + pass + + +class UnknownInstanceError(ValueError): + pass + + +def load_benchmark_instance_ids(benchmark: str) -> set[str]: + """Return every ``instance_id`` declared in the benchmark's task data + JSONL.""" + path = paths.benchmark_data_file(benchmark) + ids: set[str] = set() + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + obj = json.loads(line) + iid = obj.get("instance_id") + if iid: + ids.add(str(iid)) + return ids + + +def assert_coverage_ok( + *, + benchmark: str, + present_instance_ids: set[str], + allow_partial: bool, +) -> None: + """Raise if the present set isn't equal to the full benchmark split. + + * Missing instances → ``IncompleteCoverageError`` unless ``allow_partial``. + * Extra instances → ``UnknownInstanceError`` regardless. + """ + full = load_benchmark_instance_ids(benchmark) + extra = sorted(present_instance_ids - full) + if extra: + raise UnknownInstanceError( + f"instance_id(s) not in benchmark {benchmark!r}: " + f"{', '.join(extra)}. Check for typos in your selection file." + ) + missing = sorted(full - present_instance_ids) + if missing and not allow_partial: + raise IncompleteCoverageError( + f"benchmark {benchmark!r} has {len(full)} instances; " + f"submission covers {len(present_instance_ids)}. Missing: " + f"{', '.join(missing)}. Pass --allow-partial to override." + ) diff --git a/src/bird_interact_agents/reports/leakage.py b/src/bird_interact_agents/reports/leakage.py new file mode 100644 index 00000000..1796de39 --- /dev/null +++ b/src/bird_interact_agents/reports/leakage.py @@ -0,0 +1,49 @@ +"""Optional ``--check-leakage`` diagnostic (Codex finding #2 fold-in). + +Scans every prompt for case-insensitive substrings of the +``ground_truth_sql``. Reports a count into ``manifest.leakage_check``; +NEVER redacts. The leaderboard's 15-expert user-simulator review is the +authoritative leakage validation channel; this is just a quick sanity +check we can run before mailing the submission. +""" + +from __future__ import annotations + + +def _gold_substrings(gold: str, min_substring: int) -> list[str]: + """Sliding-window substrings of length ``min_substring`` over ``gold``. + + A prompt that contains any of them is flagged as 1 leak. We dedupe to + avoid double-counting overlapping windows when the gold has repeated + spans. + """ + if not gold or len(gold) < min_substring: + return [] + seen: set[str] = set() + for i in range(len(gold) - min_substring + 1): + seen.add(gold[i : i + min_substring].lower()) + return list(seen) + + +def count_leakage( + *, + prompts: list[str], + ground_truth_sql: str | None, + min_substring: int = 12, +) -> int: + """Return the number of prompts that contain ANY substring of the gold + SQL ≥ ``min_substring`` chars long. + + Empty or short golds → 0 (the threshold itself filters them out). + """ + if not ground_truth_sql: + return 0 + needles = _gold_substrings(ground_truth_sql, min_substring) + if not needles: + return 0 + n = 0 + for prompt in prompts: + haystack = (prompt or "").lower() + if any(needle in haystack for needle in needles): + n += 1 + return n diff --git a/src/bird_interact_agents/reports/output.py b/src/bird_interact_agents/reports/output.py new file mode 100644 index 00000000..5a7c184b --- /dev/null +++ b/src/bird_interact_agents/reports/output.py @@ -0,0 +1,99 @@ +"""Submission output writer: submission.jsonl + email_title.txt + manifest.json.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from bird_interact_agents.reports.cost import ( + FIXED_COSTS, + SECTION_VI_THRESHOLDS, +) +from bird_interact_agents.reports.schema import SubmissionRow +from bird_interact_agents.reports.tokens import DEFAULT_MODEL + + +_BENCHMARK_TO_SPLIT: dict[str, str] = { + "bird-interact-lite-exp": "lite", + "bird-interact-full": "full", + "mini-interact": "mini-interact", +} + + +def build_email_title( + *, benchmark: str, setting: str, team: str, method: str +) -> str: + if benchmark not in _BENCHMARK_TO_SPLIT: + raise ValueError( + f"benchmark {benchmark!r} is not an a-Interact split; " + f"supported: {sorted(_BENCHMARK_TO_SPLIT)}" + ) + split = _BENCHMARK_TO_SPLIT[benchmark] + return f"[BIRD-INTERACT-1.0-{split}][{setting}][{team}][{method}]" + + +@dataclass +class ManifestPlan: + benchmark: str + setting: str + split: str + team: str + method: str + tag: str + selection_mode: str + source_run_ids: list[str] + generated_at: str + instances: list[dict[str, Any]] = field(default_factory=list) + patience_resolution: list[dict[str, Any]] = field(default_factory=list) + leakage_check: dict[str, Any] | None = None + warnings_by_instance: list[dict[str, Any]] = field(default_factory=list) + + +def write_submission( + *, rows: list[SubmissionRow], plan: ManifestPlan, out_dir: Path +) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + + # 1. submission.jsonl + jsonl_path = out_dir / "submission.jsonl" + with jsonl_path.open("w") as f: + for row in rows: + obj = row.model_dump() + f.write(json.dumps(obj, separators=(",", ":"))) + f.write("\n") + + # 2. email_title.txt — no trailing newline. + title = build_email_title( + benchmark=plan.benchmark, + setting=plan.setting, + team=plan.team, + method=plan.method, + ) + (out_dir / "email_title.txt").write_text(title) + + # 3. manifest.json + manifest = { + "schema_version": 1, + "kind": "bird_interact_submission_manifest", + "generated_at": plan.generated_at, + "benchmark": plan.benchmark, + "split": plan.split, + "setting": plan.setting, + "team": plan.team, + "method": plan.method, + "tag": plan.tag, + "n_instances": len(rows), + "selection_mode": plan.selection_mode, + "source_run_ids": plan.source_run_ids, + "instances": plan.instances, + "patience_resolution": plan.patience_resolution, + "section_vi_threshold": SECTION_VI_THRESHOLDS, + "fixed_costs": FIXED_COSTS, + "tokenizer": f"anthropic.messages.count_tokens(model={DEFAULT_MODEL})", + "leakage_check": plan.leakage_check, + "warnings_by_instance": plan.warnings_by_instance, + } + (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) + return out_dir diff --git a/src/bird_interact_agents/reports/phase_split.py b/src/bird_interact_agents/reports/phase_split.py new file mode 100644 index 00000000..5b0b1e62 --- /dev/null +++ b/src/bird_interact_agents/reports/phase_split.py @@ -0,0 +1,118 @@ +"""Per-submit phase classifier. + +The bird-interact-tools submit tool emits one of the verdict strings in +``action_handler_sqlite``: + +* ``Phase 1 SQL Correct! (Reward: X points). Moving to Phase 2.`` +* ``Phase 1 SQL Correct! (Reward: X points). No Phase 2. Task finished.`` +* ``Phase 2 SQL Correct! (Reward: X points). Task finished.`` +* ``Submitted SQL failed test case in Phase {1|2}. Reason: …`` + +We classify each observation directly from the marker text; only when +markers are missing or ordered inconsistently do we fall back to a +warning-emitting heuristic. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + + +_RE_CORRECT = re.compile(r"Phase\s+([12])\s+SQL\s+Correct", re.IGNORECASE) +_RE_WRONG = re.compile( + r"Submitted\s+SQL\s+failed\s+test\s+case\s+in\s+Phase\s+([12])", + re.IGNORECASE, +) + + +def _to_text(observation: Any) -> str: + """Normalise observation to a single string. Tool_result content can + arrive as a string, a list of text-blocks ({type: text, text: …}), + or a bare list of strings.""" + if observation is None: + return "" + if isinstance(observation, str): + return observation + if isinstance(observation, list): + chunks: list[str] = [] + for item in observation: + if isinstance(item, dict): + # Anthropic SDK content block. + if "text" in item: + chunks.append(str(item["text"])) + else: + chunks.append(str(item)) + else: + chunks.append(str(item)) + return "\n".join(chunks) + return str(observation) + + +def classify_submit_observation(observation: Any) -> tuple[str | None, str | None]: + """Return ``(phase, verdict)`` for one submit observation. + + ``phase`` ∈ {"phase1", "phase2", None}. ``verdict`` ∈ {"correct", + "wrong", None}. Both ``None`` when no marker is present. + """ + text = _to_text(observation) + m = _RE_CORRECT.search(text) + if m: + return (f"phase{m.group(1)}", "correct") + m = _RE_WRONG.search(text) + if m: + return (f"phase{m.group(1)}", "wrong") + return (None, None) + + +@dataclass +class SplitResult: + labels: list[str] + warnings: list[str] + + +def split_phases(observations: list[Any]) -> SplitResult: + """Classify every submit observation. Emit warnings for missing + markers (per-observation) or inconsistent marker ordering.""" + labels: list[str] = [] + warnings: list[str] = [] + saw_phase1_correct = False + saw_phase2_marker_before_phase1 = False + unknown_count = 0 + saw_any_marker = False + + for obs in observations: + phase, verdict = classify_submit_observation(obs) + if phase is None: + # Fallback: before any phase-1 correct marker, treat as phase-1; + # after, phase-2. If we never see any marker at all we'll + # emit a single warning at the end. + labels.append("phase2" if saw_phase1_correct else "phase1") + unknown_count += 1 + continue + saw_any_marker = True + labels.append(phase) + if phase == "phase2" and not saw_phase1_correct: + saw_phase2_marker_before_phase1 = True + if phase == "phase1" and verdict == "correct": + saw_phase1_correct = True + + if unknown_count and not saw_any_marker: + warnings.append( + f"no phase markers detected across {unknown_count} submit observation(s); " + "defaulted every submit to phase-1" + ) + elif unknown_count: + warnings.append( + f"phase markers missing on {unknown_count} submit observation(s); " + "used last-seen-correct heuristic" + ) + + if saw_phase2_marker_before_phase1: + warnings.append( + "phase-2 marker observed before any phase-1 success marker; " + "this is unusual — labels follow the markers as observed" + ) + + return SplitResult(labels=labels, warnings=warnings) diff --git a/src/bird_interact_agents/reports/schema.py b/src/bird_interact_agents/reports/schema.py new file mode 100644 index 00000000..75878122 --- /dev/null +++ b/src/bird_interact_agents/reports/schema.py @@ -0,0 +1,38 @@ +"""Pydantic models for the submission JSONL row + per-step prompt_flow entry. + +The serialization shape is pinned by Section II of the BIRD-INTERACT-1.0 +submission guidelines (a-Interact custom-agent variant): + +* Per-instance: ``instance_id``, ``subtask_1_predicted_sql`` (list[str]), + ``subtask_2_predicted_sql`` (list[str]), ``prompt_flow``. +* Per ``prompt_flow`` entry: ``model``, ``user_simulator``, ``prompt``, + ``response``, ``action``, ``remaining_budget``, ``action_input_tokens``, + ``action_output_tokens``, ``action_cost``. +""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class PromptFlowEntry(BaseModel): + model: str + user_simulator: str + prompt: str + response: str + action: str + remaining_budget: float + action_input_tokens: int + action_output_tokens: int + action_cost: float + + model_config = {"extra": "forbid"} + + +class SubmissionRow(BaseModel): + instance_id: str + subtask_1_predicted_sql: list[str] + subtask_2_predicted_sql: list[str] + prompt_flow: list[PromptFlowEntry] + + model_config = {"extra": "forbid"} diff --git a/src/bird_interact_agents/reports/selection.py b/src/bird_interact_agents/reports/selection.py new file mode 100644 index 00000000..db4ca3e4 --- /dev/null +++ b/src/bird_interact_agents/reports/selection.py @@ -0,0 +1,48 @@ +"""Selection.jsonl loader. + +Each line is a JSON object ``{"instance_id": "...", "run_id": "..."}``. +Duplicate ``instance_id`` is a hard error listing every duplicate. +""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path + + +class DuplicateInstanceError(ValueError): + pass + + +def load_selection(path: Path | str) -> list[tuple[str, str]]: + """Return ``[(instance_id, run_id), …]`` in file order.""" + p = Path(path) + out: list[tuple[str, str]] = [] + seen: list[str] = [] + with p.open() as f: + for line_no, line in enumerate(f, start=1): + line = line.strip() + if not line: + continue + obj = json.loads(line) + try: + inst = obj["instance_id"] + run = obj["run_id"] + except KeyError as e: + raise KeyError( + f"{p}:{line_no} selection entry missing required " + f"field {e.args[0]!r}: {obj!r}" + ) + out.append((str(inst), str(run))) + seen.append(str(inst)) + + dupes = sorted( + {iid for iid, count in Counter(seen).items() if count > 1} + ) + if dupes: + raise DuplicateInstanceError( + f"{p}: selection has duplicate instance_id entries: " + f"{', '.join(dupes)}. Each instance must map to exactly one run_id." + ) + return out diff --git a/src/bird_interact_agents/reports/sources.py b/src/bird_interact_agents/reports/sources.py new file mode 100644 index 00000000..6b782736 --- /dev/null +++ b/src/bird_interact_agents/reports/sources.py @@ -0,0 +1,226 @@ +"""Source resolution: locate trajectory.json + results.db for each +``(instance_id, run_id)`` and read run metadata. + +Hard errors: +* Missing trajectory.json → ``MissingTrajectoryError`` listing every + missing entry. +* Trajectory.json present but ``trajectory`` array absent (older mini- + interact placeholder) → ``StubTrajectoryError``. +* Missing results.db → ``FileNotFoundError``. +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +from bird_interact_agents import paths + + +class MissingTrajectoryError(FileNotFoundError): + pass + + +class StubTrajectoryError(ValueError): + pass + + +class MissingTaskResultsError(ValueError): + pass + + +class DuplicateTaskResultsError(ValueError): + pass + + +@dataclass +class InstanceSource: + instance_id: str + run_id: str + database: str + trajectory_path: Path + results_db_path: Path + framework: str + agent_model: str + user_sim_model: str + trajectory_obj: dict + task_results_row: dict + # Per-row (framework can also be in run_metadata but query_mode + mode + # are per-task; we use the task_results values as authoritative). + mode: str + query_mode: str + + +def _read_results_db(results_db_path: Path, run_id: str) -> tuple[dict, dict[str, dict]]: + con = sqlite3.connect(results_db_path) + con.row_factory = sqlite3.Row + try: + meta_rows = con.execute( + "SELECT * FROM run_metadata WHERE run_id = ?", (run_id,) + ).fetchall() + if not meta_rows: + raise ValueError( + f"{results_db_path}: run_metadata has no row for run_id={run_id!r}" + ) + meta = dict(meta_rows[0]) + task_rows = con.execute( + "SELECT * FROM task_results WHERE run_id = ?", (run_id,) + ).fetchall() + # Codex round 5 finding: task_results' composite key includes + # framework/mode/query_mode, so in principle a single run_id + # could carry multiple rows per instance_id. The harness never + # writes such rows today, but a silent dict overwrite would + # pick whichever row SQLite returns last, potentially landing + # on the wrong mode + flipping adapter / a-Interact-gate + # decisions downstream. Raise instead. + by_instance: dict[str, dict] = {} + dupes: list[str] = [] + for row in task_rows: + iid = row["instance_id"] + if iid in by_instance: + dupes.append(iid) + by_instance[iid] = dict(row) + if dupes: + raise DuplicateTaskResultsError( + f"{results_db_path}: task_results has multiple rows for " + f"run_id={run_id!r} on instance_id(s): " + f"{', '.join(sorted(set(dupes)))}. Each (run_id, instance_id) " + f"must be unique — a duplicate indicates a corrupt or " + f"mixed-mode run." + ) + finally: + con.close() + return meta, by_instance + + +def _instance_dir(benchmark: str, db: str, instance_id: str) -> Path: + return paths.runs_root() / benchmark / db / instance_id + + +def _find_database_for_instance( + benchmark: str, instance_id: str +) -> str | None: + """Walk ``runs//*//`` to find the ``db`` + subdirectory that contains this instance's trajectory.""" + bench_root = paths.runs_root() / benchmark + if not bench_root.is_dir(): + return None + for db_dir in bench_root.iterdir(): + if not db_dir.is_dir(): + continue + if (db_dir / instance_id).is_dir(): + return db_dir.name + return None + + +def resolve_sources( + *, + selection: list[tuple[str, str]], + benchmark: str, +) -> dict[str, InstanceSource]: + """Return ``{instance_id: InstanceSource}`` for the entire selection. + + Raises ``MissingTrajectoryError`` / ``StubTrajectoryError`` / + ``FileNotFoundError`` for any unresolvable entry, listing all + offenders. + """ + # Group by run_id so we open each results.db once. + by_run: dict[str, list[str]] = {} + for inst_id, run_id in selection: + by_run.setdefault(run_id, []).append(inst_id) + + sources: dict[str, InstanceSource] = {} + missing_traj: list[tuple[str, str, Path]] = [] + stub_traj: list[tuple[str, str, Path]] = [] + missing_task: list[tuple[str, str]] = [] + + for run_id, inst_ids in by_run.items(): + results_db_path = ( + paths.results_root() / benchmark / "cloud" / run_id / "results.db" + ) + if not results_db_path.is_file(): + raise FileNotFoundError( + f"{results_db_path} does not exist (run_id={run_id!r}, " + f"benchmark={benchmark!r})" + ) + meta, task_rows_by_inst = _read_results_db(results_db_path, run_id) + + for inst_id in inst_ids: + # Find the database subdir. + db_name = task_rows_by_inst.get(inst_id, {}).get("database") + if not db_name: + db_name = _find_database_for_instance(benchmark, inst_id) + if not db_name: + missing_traj.append( + (inst_id, run_id, paths.runs_root() / benchmark) + ) + continue + + traj_path = ( + _instance_dir(benchmark, db_name, inst_id) + / f"{run_id}.trajectory.json" + ) + if not traj_path.is_file(): + missing_traj.append((inst_id, run_id, traj_path)) + continue + traj_obj = json.loads(traj_path.read_text()) + if "trajectory" not in traj_obj or not isinstance( + traj_obj.get("trajectory"), list + ): + stub_traj.append((inst_id, run_id, traj_path)) + continue + + task_row = task_rows_by_inst.get(inst_id) + if not task_row: + # No row in results.db for this instance — Codex round 3 + # finding: empty task_row makes mode='' which silently + # bypasses the a-Interact gate. Refuse here. + missing_task.append((inst_id, run_id)) + continue + sources[inst_id] = InstanceSource( + instance_id=inst_id, + run_id=run_id, + database=db_name, + trajectory_path=traj_path, + results_db_path=results_db_path, + framework=str(meta.get("framework") or ""), + agent_model=str(meta.get("agent_model") or ""), + user_sim_model=str(meta.get("user_sim_model") or ""), + trajectory_obj=traj_obj, + task_results_row=task_row, + mode=str(task_row.get("mode") or meta.get("mode") or ""), + query_mode=str(task_row.get("query_mode") or ""), + ) + + if missing_traj: + lines = "\n".join( + f" - instance_id={iid} run_id={rid} expected at {p}" + for iid, rid, p in missing_traj + ) + raise MissingTrajectoryError( + f"trajectory.json missing for {len(missing_traj)} selection " + f"entries (benchmark={benchmark!r}):\n{lines}" + ) + if stub_traj: + lines = "\n".join( + f" - instance_id={iid} run_id={rid} at {p}" + for iid, rid, p in stub_traj + ) + raise StubTrajectoryError( + f"stub-only trajectory.json (no `trajectory` array) for " + f"{len(stub_traj)} entries — cannot reconstruct prompt_flow:\n{lines}" + ) + if missing_task: + lines = "\n".join( + f" - instance_id={iid} run_id={rid}" + for iid, rid in missing_task + ) + raise MissingTaskResultsError( + f"results.db has no task_results row for {len(missing_task)} " + f"selection entries — the run never recorded these instances, " + f"so mode / query_mode / final SQL / phase results are all " + f"unknown:\n{lines}" + ) + return sources diff --git a/src/bird_interact_agents/reports/tokens.py b/src/bird_interact_agents/reports/tokens.py new file mode 100644 index 00000000..96aff949 --- /dev/null +++ b/src/bird_interact_agents/reports/tokens.py @@ -0,0 +1,64 @@ +"""Anthropic-SDK token counter with envelope-baseline subtraction. + +Counts tokens for a single string by wrapping it as a one-message +``user`` message and calling ``anthropic.Anthropic().messages.count_tokens``. +We subtract a once-per-process baseline = ``count_tokens("")`` so the +Section VI 250 / 1000 thresholds are contract-exact (no wrapper bias). + +Tests monkeypatch ``count_tokens`` to a deterministic char-based fake; +production runs call the live API (free; no LLM rollout). +""" + +from __future__ import annotations + +import functools +import os +from typing import Any + + +DEFAULT_MODEL = "claude-haiku-4-5-20251001" + +_BASELINE_BY_MODEL: dict[str, int] = {} + + +def _count_tokens_via_api(messages: list[dict[str, Any]], *, model: str) -> Any: + """Real Anthropic API call. Tests stub THIS via monkeypatching.""" + # Lazy-import so tests that fake the function never touch anthropic. + from anthropic import Anthropic + + client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + return client.messages.count_tokens(model=model, messages=messages) + + +def _baseline(model: str) -> int: + if model not in _BASELINE_BY_MODEL: + result = _count_tokens_via_api( + messages=[{"role": "user", "content": ""}], model=model + ) + _BASELINE_BY_MODEL[model] = int(result.input_tokens) + return _BASELINE_BY_MODEL[model] + + +def _reset_baseline_cache() -> None: + """Test seam: re-prime the baseline next time count_tokens is called.""" + _BASELINE_BY_MODEL.clear() + cache_clear = getattr(count_tokens, "cache_clear", None) + if cache_clear is not None: + cache_clear() + + +@functools.lru_cache(maxsize=10_000) +def count_tokens(s: str, *, model: str = DEFAULT_MODEL) -> int: + """Return ``Anthropic.messages.count_tokens(s) - baseline(model)``. + + Cached by ``(s, model)`` — submit-SQL strings often repeat across + retries. + """ + if not s: + # Trivial fast path: empty content is 0 by definition (envelope + # subtracted). + return 0 + result = _count_tokens_via_api( + messages=[{"role": "user", "content": s}], model=model + ) + return max(0, int(result.input_tokens) - _baseline(model)) diff --git a/tests/cloud/test_submission_cli.py b/tests/cloud/test_submission_cli.py new file mode 100644 index 00000000..f86e3e63 --- /dev/null +++ b/tests/cloud/test_submission_cli.py @@ -0,0 +1,1163 @@ +"""Tests for the ``bird-interact-cloud submission`` subcommand. + +Spec (DEV-1553) tests #13 (CLI argparse + dispatch) + #19 (selection +coverage), plus an end-to-end smoke that exercises the converter + +output writer through the CLI entry point. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tests.reports._fixtures import ( + stage_run, + trajectory_one_phase_pass, + trajectory_two_phase_pass, +) + + +# --------------------------------------------------------------------------- +# Argparse contract +# --------------------------------------------------------------------------- + + +def test_read_patience_returns_default_on_non_numeric_value(tmp_path: Path): + """CodeRabbit #1: ``int(pat)`` must not crash on a non-numeric + patience field — it falls back to ``(None, "default")`` consistent + with the function's existing JSON / file-IO defensiveness.""" + from bird_interact_agents.reports.cli import _read_patience_for_instance + + inst_dir = tmp_path / "inst" + inst_dir.mkdir() + (inst_dir / "r1.json").write_text(json.dumps({"patience": "not-a-number"})) + assert _read_patience_for_instance(inst_dir, "r1") == (None, "default") + + +def test_read_patience_returns_default_on_malformed_json(tmp_path: Path): + from bird_interact_agents.reports.cli import _read_patience_for_instance + + inst_dir = tmp_path / "inst" + inst_dir.mkdir() + (inst_dir / "r1.json").write_text("not-json{{{") + assert _read_patience_for_instance(inst_dir, "r1") == (None, "default") + + +def test_read_patience_reads_numeric_value(tmp_path: Path): + from bird_interact_agents.reports.cli import _read_patience_for_instance + + inst_dir = tmp_path / "inst" + inst_dir.mkdir() + (inst_dir / "r1.json").write_text(json.dumps({"patience": 7})) + patience, source = _read_patience_for_instance(inst_dir, "r1") + assert patience == 7 + assert source == "runs:r1.json" + + +def test_submission_requires_run_id_or_selection(): + from bird_interact_agents.cloud.cli import main + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer", + "--benchmark", + "bird-interact-lite-exp", + ] + ) + + +def test_submission_rejects_both_run_id_and_selection(tmp_path): + from bird_interact_agents.cloud.cli import main + + sel = tmp_path / "sel.jsonl" + sel.write_text("") + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "r1", + "--selection", + str(sel), + ] + ) + + +def test_submission_requires_team_and_method(tmp_path): + from bird_interact_agents.cloud.cli import main + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "r1", + ] + ) + + +def test_submission_benchmark_rejects_out_of_scope(tmp_path): + """Only the three a-Interact benchmarks are accepted.""" + from bird_interact_agents.cloud.cli import main + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer", + "--benchmark", + "livesqlbench-base-lite-sqlite", + "--run-id", + "r1", + ] + ) + + +# --------------------------------------------------------------------------- +# End-to-end: --run-id with full coverage produces a usable submission dir +# --------------------------------------------------------------------------- + + +def _stub_split(monkeypatch, instance_ids): + from bird_interact_agents.reports import coverage as _cov + + monkeypatch.setattr( + _cov, "load_benchmark_instance_ids", lambda benchmark: set(instance_ids) + ) + + +def _stub_fake_tokenizer(monkeypatch): + from bird_interact_agents.reports import tokens as _tokens + + def _fake(s, *, model="claude-haiku-4-5-20251001"): + return max(1, len(s) // 4) + + monkeypatch.setattr(_tokens, "count_tokens", _fake) + + +def test_submission_end_to_end_run_id_full_coverage( + tmp_path: Path, monkeypatch +): + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ("alien", "alien_2", trajectory_two_phase_pass(instance_id="alien_2")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1", "alien_2"}) + _stub_fake_tokenizer(monkeypatch) + + # Stub task_data lookup (zero ambiguities → budget = 12). + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + assert rc == 0 + + # Verify artefacts. + out_root = tmp_path / "reports" / "bird-interact-lite-exp" / "a-Interact" + [sub_dir] = list(out_root.iterdir()) + assert (sub_dir / "submission.jsonl").exists() + assert (sub_dir / "email_title.txt").exists() + assert (sub_dir / "manifest.json").exists() + + title = (sub_dir / "email_title.txt").read_text() + assert title == "[BIRD-INTERACT-1.0-lite][a-Interact][Motley][SLayer-Agent]" + + lines = (sub_dir / "submission.jsonl").read_text().splitlines() + assert len(lines) == 2 + ids = {json.loads(line)["instance_id"] for line in lines} + assert ids == {"alien_1", "alien_2"} + + +# --------------------------------------------------------------------------- +# --selection coverage check (Codex finding #1) +# --------------------------------------------------------------------------- + + +def test_submission_selection_partial_aborts_without_allow_partial( + tmp_path: Path, monkeypatch +): + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1", "alien_2", "alien_3"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + sel_path = tmp_path / "sel.jsonl" + sel_path.write_text(json.dumps({"instance_id": "alien_1", "run_id": "run-xyz"}) + "\n") + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--selection", + str(sel_path), + ] + ) + + +def test_submission_selection_partial_with_allow_partial_succeeds( + tmp_path: Path, monkeypatch +): + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1", "alien_2", "alien_3"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + sel_path = tmp_path / "sel.jsonl" + sel_path.write_text(json.dumps({"instance_id": "alien_1", "run_id": "run-xyz"}) + "\n") + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--selection", + str(sel_path), + "--allow-partial", + ] + ) + assert rc == 0 + + +# --------------------------------------------------------------------------- +# --patience flag +# --------------------------------------------------------------------------- + + +def test_submission_run_id_partial_coverage_aborts_without_allow_partial( + tmp_path: Path, monkeypatch +): + """The --run-id path also enforces full coverage by default.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1", "alien_2"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + + +def test_submission_run_id_partial_coverage_with_allow_partial_succeeds( + tmp_path: Path, monkeypatch +): + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1", "alien_2"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + "--allow-partial", + ] + ) + assert rc == 0 + + +def test_submission_no_thinking_flag_strips_thinking( + tmp_path: Path, monkeypatch +): + """--no-thinking CLI flag flips the converter's include_thinking to False.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + thinking="thinking content", + text="text content", + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": "SELECT 1"}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="Phase 1 SQL Correct!"), + ] + traj = build_trajectory( + instance_id="alien_1", trajectory_steps=steps, submitted_sql="SELECT 1" + ) + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[("alien", "alien_1", traj)], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + "--no-thinking", + ] + ) + assert rc == 0 + + out_root = tmp_path / "reports" / "bird-interact-lite-exp" / "a-Interact" + [sub_dir] = list(out_root.iterdir()) + row = json.loads((sub_dir / "submission.jsonl").read_text().splitlines()[0]) + assert "thinking" not in row["prompt_flow"][0]["response"] + + +def test_submission_check_leakage_flag_writes_manifest_counts( + tmp_path: Path, monkeypatch +): + """--check-leakage scans each instance's prompts for gold-SQL substrings + and records per-instance counts in manifest.leakage_check.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + gold = "SELECT trader.id FROM trader JOIN compliancecase ON x = y" + steps = [ + system_msg(), + user_text_msg(text=f"Hint: try `{gold}`."), # gold leaked in initial prompt + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": gold}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="Phase 1 SQL Correct!"), + ] + traj = build_trajectory( + instance_id="alien_1", + trajectory_steps=steps, + submitted_sql=gold, + ground_truth_sql=gold, + ) + clean_traj = trajectory_one_phase_pass(instance_id="alien_2", sql="SELECT 1") + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", traj), + ("alien", "alien_2", clean_traj), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1", "alien_2"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + "--check-leakage", + ] + ) + assert rc == 0 + + out_root = tmp_path / "reports" / "bird-interact-lite-exp" / "a-Interact" + [sub_dir] = list(out_root.iterdir()) + mf = json.loads((sub_dir / "manifest.json").read_text()) + leak = mf["leakage_check"] + assert leak is not None + counts_by_id = { + e["instance_id"]: e["leak_count"] for e in leak["per_instance"] + } + assert counts_by_id["alien_1"] >= 1 + assert counts_by_id["alien_2"] == 0 + # The submission rows themselves are unchanged — no redaction. + rows = [ + json.loads(line) + for line in (sub_dir / "submission.jsonl").read_text().splitlines() + ] + leaky_row = next(r for r in rows if r["instance_id"] == "alien_1") + assert gold in leaky_row["prompt_flow"][0]["prompt"] + + +def test_submission_uses_run_level_manifest_patience( + tmp_path: Path, monkeypatch +): + """Codex round 3 finding: real cloud runs store ``patience`` in + ``//cloud//manifest.json``, NOT in the + per-instance submission-annotation sidecar. The CLI must read the + run-level manifest before falling back to the ``--patience`` CLI + default.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + # Write the run-level cloud manifest with patience=500 (mirrors how + # the cloud driver writes it). The CLI's --patience flag stays at + # default 3; the run-level manifest must win. + run_mf_path = ( + results_root / "bird-interact-lite-exp" / "cloud" / "run-xyz" / "manifest.json" + ) + run_mf_path.write_text(json.dumps({"patience": 500})) + + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + assert rc == 0 + + out_root = tmp_path / "reports" / "bird-interact-lite-exp" / "a-Interact" + [sub_dir] = list(out_root.iterdir()) + mf = json.loads((sub_dir / "manifest.json").read_text()) + [pat_entry] = mf["patience_resolution"] + assert pat_entry["patience"] == 500 + assert "manifest.json" in pat_entry["source"] + # And the submission row's first step has remaining_budget = + # max(0, total_budget - submit_cost) where total = 6 + 0 + 2*500 = 1006. + row = json.loads((sub_dir / "submission.jsonl").read_text().splitlines()[0]) + assert row["prompt_flow"][0]["remaining_budget"] == 1003.0 + + +def test_submission_aborts_on_empty_selection_file( + tmp_path: Path, monkeypatch +): + """Codex round 8: an empty selection file must produce a clean + SystemExit(2), not silently emit an empty submission.jsonl.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + sel = tmp_path / "empty.jsonl" + sel.write_text("") + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--selection", + str(sel), + ] + ) + + +def test_submission_aborts_on_empty_run_id_task_results( + tmp_path: Path, monkeypatch +): + """A --run-id whose results.db has no task_results rows must also + produce a clean SystemExit(2). --allow-partial does NOT permit a + zero-instance submission.""" + import sqlite3 + + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + db = results_root / "bird-interact-lite-exp" / "cloud" / "run-xyz" / "results.db" + con = sqlite3.connect(db) + con.execute("DELETE FROM task_results") + con.commit() + con.close() + + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + "--allow-partial", + ] + ) + + +def test_submission_cli_handles_malformed_selection_cleanly( + tmp_path: Path, monkeypatch +): + """Selection-file parse errors (duplicate instance_id, malformed + JSON, missing field) MUST translate to SystemExit(2), never an + uncaught traceback.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + sel = tmp_path / "dupes.jsonl" + sel.write_text( + json.dumps({"instance_id": "alien_1", "run_id": "r1"}) + "\n" + + json.dumps({"instance_id": "alien_1", "run_id": "r2"}) + "\n" + ) + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--selection", + str(sel), + ] + ) + + +def test_submission_aborts_on_raw_query_mode( + tmp_path: Path, monkeypatch +): + """Codex round 7: a row whose mode='a-interact' (so the mode gate + passes) but query_mode='raw' must hit the dedicated query-mode gate + with a clean SystemExit(2), NOT crash with `UnknownFrameworkError` + inside the converter.""" + import sqlite3 + + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + db = results_root / "bird-interact-lite-exp" / "cloud" / "run-xyz" / "results.db" + con = sqlite3.connect(db) + con.execute( + "UPDATE task_results SET query_mode = 'raw' WHERE instance_id = ?", + ("alien_1",), + ) + con.commit() + con.close() + + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + + +def test_submission_aborts_on_empty_query_mode( + tmp_path: Path, monkeypatch +): + """Empty query_mode must NOT be silently treated as 'slayer'.""" + import sqlite3 + + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + db = results_root / "bird-interact-lite-exp" / "cloud" / "run-xyz" / "results.db" + con = sqlite3.connect(db) + con.execute( + "UPDATE task_results SET query_mode = '' WHERE instance_id = ?", + ("alien_1",), + ) + con.commit() + con.close() + + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + + +def test_submission_aborts_on_empty_mode_value( + tmp_path: Path, monkeypatch +): + """Codex round 6: a task_results row whose `mode` column is empty + or null must NOT silently pass the a-Interact gate.""" + import sqlite3 + + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + # Blank-out the mode column on the staged row to simulate a + # malformed results.db. + db = results_root / "bird-interact-lite-exp" / "cloud" / "run-xyz" / "results.db" + con = sqlite3.connect(db) + con.execute( + "UPDATE task_results SET mode = '' WHERE instance_id = ?", + ("alien_1",), + ) + con.execute( + "UPDATE run_metadata SET mode = '' WHERE run_id = ?", ("run-xyz",) + ) + con.commit() + con.close() + + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + + +def test_submission_cli_handles_duplicate_task_results_cleanly( + tmp_path: Path, monkeypatch +): + """Codex round 6: the `DuplicateTaskResultsError` raised by + `_read_results_db` must be translated into a clean SystemExit(2), + not bubble up as an uncaught traceback.""" + import sqlite3 + + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + # Inject a duplicate row. + db = results_root / "bird-interact-lite-exp" / "cloud" / "run-xyz" / "results.db" + con = sqlite3.connect(db) + con.execute( + "INSERT INTO task_results (run_id, instance_id, mode, query_mode, " + "framework, database, phase1_passed, phase2_passed) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ("run-xyz", "alien_1", "one-shot", "raw", "claude_sdk", "alien", 0, 0), + ) + con.commit() + con.close() + + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + + +def test_submission_aborts_on_non_a_interact_run( + tmp_path: Path, monkeypatch +): + """Per the DEV-1553 spec, the submission generator is a-Interact-only. + A run staged with mode='one-shot' must abort with SystemExit and a + clear stderr listing the offenders.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + mode="one-shot", # <-- not a-interact + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + with pytest.raises(SystemExit): + main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + + +def test_submission_surfaces_phase_split_warnings_in_manifest( + tmp_path: Path, monkeypatch +): + """split_phases() returns warnings on missing/inconsistent markers. + Those warnings MUST land in manifest.warnings_by_instance so the + operator sees them — Codex finding round 2.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + # Trajectory with a submit whose observation has NO phase marker — + # split_phases falls back to phase-1 and emits "no phase markers" + # warning. + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": "SELECT 1"}, + ), + ), + tool_result_msg( + tool_use_id="tu_1", + content="unrelated observation text without any marker", + ), + ] + traj = build_trajectory( + instance_id="alien_1", trajectory_steps=steps, submitted_sql="SELECT 1" + ) + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[("alien", "alien_1", traj)], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + ] + ) + assert rc == 0 + + out_root = tmp_path / "reports" / "bird-interact-lite-exp" / "a-Interact" + [sub_dir] = list(out_root.iterdir()) + mf = json.loads((sub_dir / "manifest.json").read_text()) + flat = [ + w + for entry in mf["warnings_by_instance"] + for w in entry["warnings"] + ] + assert any("phase markers" in w.lower() for w in flat), flat + + +def test_submission_patience_flag_changes_total_budget( + tmp_path: Path, monkeypatch +): + """Bumping --patience changes the replayed remaining_budget headroom.""" + from bird_interact_agents import paths + from bird_interact_agents.cloud.cli import main + + runs_root, results_root = stage_run( + tmp_path, + benchmark="bird-interact-lite-exp", + run_id="run-xyz", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + monkeypatch.setattr(paths, "reports_root", lambda: tmp_path / "reports") + _stub_split(monkeypatch, {"alien_1"}) + _stub_fake_tokenizer(monkeypatch) + monkeypatch.setattr( + "bird_interact_agents.reports.budget.lookup_task_data", + lambda benchmark, instance_id: { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [], + }, + ) + + rc = main( + [ + "submission", + "--team-name", + "Motley", + "--method-name", + "SLayer-Agent", + "--benchmark", + "bird-interact-lite-exp", + "--run-id", + "run-xyz", + "--patience", + "500", + ] + ) + assert rc == 0 + + out_root = tmp_path / "reports" / "bird-interact-lite-exp" / "a-Interact" + [sub_dir] = list(out_root.iterdir()) + row = json.loads((sub_dir / "submission.jsonl").read_text().splitlines()[0]) + # total_budget with patience=500, amb=0: 6 + 0 + 2*500 = 1006. Submit + # cost = 3 → remaining_budget = 1003. + assert row["prompt_flow"][0]["remaining_budget"] == 1003.0 diff --git a/tests/reports/__init__.py b/tests/reports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/reports/_fixtures.py b/tests/reports/_fixtures.py new file mode 100644 index 00000000..d33f36c7 --- /dev/null +++ b/tests/reports/_fixtures.py @@ -0,0 +1,412 @@ +"""Synthetic trajectory + run-layout builders for the reports test suite. + +Kept separate from ``conftest.py`` so they can be imported directly from +test modules (``from tests.reports._fixtures import …``) without depending +on pytest fixture injection. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + + +# --------------------------------------------------------------------------- +# Trajectory step builders +# --------------------------------------------------------------------------- + + +def assistant_msg( + *, + model: str = "claude-opus-4-7", + thinking: str = "", + text: str = "", + tool_use: dict[str, Any] | None = None, +) -> dict[str, Any]: + """One AssistantMessage trajectory entry.""" + content: list[dict[str, Any]] = [] + if thinking: + content.append({"thinking": thinking, "signature": "sig"}) + if text: + content.append({"text": text}) + if tool_use is not None: + content.append(tool_use) + return { + "type": "AssistantMessage", + "data": { + "content": content, + "model": model, + "parent_tool_use_id": None, + "error": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + "message_id": "msg_synthetic", + "stop_reason": "tool_use" if tool_use is not None else "end_turn", + "session_id": "sess_synthetic", + }, + } + + +def tool_use_block( + *, tool_use_id: str, name: str, inp: dict[str, Any] +) -> dict[str, Any]: + return {"id": tool_use_id, "name": name, "input": inp, "type": "tool_use"} + + +def tool_result_msg(*, tool_use_id: str, content: str) -> dict[str, Any]: + return { + "type": "UserMessage", + "data": { + "content": [ + { + "tool_use_id": tool_use_id, + "type": "tool_result", + "content": content, + } + ], + "uuid": "uuid_synthetic", + "parent_tool_use_id": None, + "tool_use_result": {"text": content}, + }, + } + + +def user_text_msg(*, text: str) -> dict[str, Any]: + return { + "type": "UserMessage", + "data": { + "content": [{"type": "text", "text": text}], + "uuid": "uuid_user_text", + "parent_tool_use_id": None, + "tool_use_result": None, + }, + } + + +def system_msg(*, text: str = "system-init") -> dict[str, Any]: + return { + "type": "SystemMessage", + "data": {"subtype": "init", "data": {"text": text}}, + } + + +# --------------------------------------------------------------------------- +# Whole-trajectory builders +# --------------------------------------------------------------------------- + + +def build_trajectory( + *, + instance_id: str = "alien_1", + database: str = "alien", + task_id: str | None = None, + phase1_passed: bool = True, + phase2_passed: bool = False, + submitted_sql: str = "SELECT 1", + ground_truth_sql: str = "SELECT 1", + submission_status: str = "passed_phase1", + phase1_observation: str | None = "Phase 1 SQL Correct! (Reward: 1 points). No Phase 2. Task finished.", + phase2_observation: str | None = None, + trajectory_steps: list[dict[str, Any]] | None = None, + error: str | None = None, + duration_s: float = 12.3, + n_agent_turns: int = 3, +) -> dict[str, Any]: + return { + "task_id": task_id or instance_id, + "instance_id": instance_id, + "database": database, + "phase1_passed": phase1_passed, + "phase2_passed": phase2_passed, + "total_reward": float(int(phase1_passed) + int(phase2_passed)), + "submitted_sql": submitted_sql, + "submitted_query": submitted_sql, + "submission_status": submission_status, + "predicted_result_json": json.dumps({"row_count": 0, "sample_rows": []}), + "gold_result_json": json.dumps({"row_count": 0, "sample_rows": []}), + "phase1_observation": phase1_observation, + "phase2_observation": phase2_observation, + "trajectory": trajectory_steps or [], + "error": error, + "usage": {"cost_usd": 0.0, "breakdown": []}, + "ground_truth_sql": ground_truth_sql, + "n_agent_turns": n_agent_turns, + "duration_s": duration_s, + } + + +def trajectory_one_phase_pass( + *, sql: str = "SELECT 1", instance_id: str = "alien_1" +) -> dict[str, Any]: + steps = [ + system_msg(), + user_text_msg(text="Find rows where x = 1."), + assistant_msg( + text="Submitting.", + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": sql}, + ), + ), + tool_result_msg( + tool_use_id="tu_1", + content="Phase 1 SQL Correct! (Reward: 1 points). No Phase 2. Task finished.", + ), + ] + return build_trajectory( + instance_id=instance_id, + submitted_sql=sql, + submission_status="passed_phase1", + trajectory_steps=steps, + ) + + +def trajectory_two_phase_pass( + *, + phase1_sql: str = "SELECT 1", + phase2_sql: str = "SELECT 2", + instance_id: str = "alien_2", +) -> dict[str, Any]: + steps = [ + system_msg(), + user_text_msg(text="Find rows."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_p1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase1_sql}, + ), + ), + tool_result_msg( + tool_use_id="tu_p1", + content="Phase 1 SQL Correct! (Reward: 1 points). Moving to Phase 2.", + ), + user_text_msg(text="Now also include the follow-up."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_p2", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase2_sql}, + ), + ), + tool_result_msg( + tool_use_id="tu_p2", + content="Phase 2 SQL Correct! (Reward: 1 points). Task finished.", + ), + ] + return build_trajectory( + instance_id=instance_id, + phase1_passed=True, + phase2_passed=True, + submitted_sql=phase2_sql, + submission_status="passed_phase2", + phase1_observation="Phase 1 SQL Correct! (Reward: 1 points). Moving to Phase 2.", + phase2_observation="Phase 2 SQL Correct! (Reward: 1 points). Task finished.", + trajectory_steps=steps, + ) + + +def trajectory_phase1_retry_then_phase2( + *, + phase1_wrong_sql: str = "SELECT 999", + phase1_right_sql: str = "SELECT 1", + phase2_sql: str = "SELECT 2", + instance_id: str = "alien_3", +) -> dict[str, Any]: + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_a", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase1_wrong_sql}, + ), + ), + tool_result_msg( + tool_use_id="tu_a", + content="Submitted SQL failed test case in Phase 1. Reason: row mismatch. Please try again.", + ), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_b", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase1_right_sql}, + ), + ), + tool_result_msg( + tool_use_id="tu_b", + content="Phase 1 SQL Correct! (Reward: 1 points). Moving to Phase 2.", + ), + user_text_msg(text="Follow-up."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_c", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase2_sql}, + ), + ), + tool_result_msg( + tool_use_id="tu_c", + content="Phase 2 SQL Correct! (Reward: 1 points). Task finished.", + ), + ] + return build_trajectory( + instance_id=instance_id, + phase1_passed=True, + phase2_passed=True, + submitted_sql=phase2_sql, + submission_status="passed_phase2", + trajectory_steps=steps, + ) + + +def trajectory_no_submits(*, instance_id: str = "alien_4") -> dict[str, Any]: + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_x", + name="mcp__bird-interact-tools__get_schema", + inp={}, + ), + ), + tool_result_msg(tool_use_id="tu_x", content="schema text"), + ] + return build_trajectory( + instance_id=instance_id, + phase1_passed=False, + phase2_passed=False, + submitted_sql="", + submission_status="error", + phase1_observation=None, + phase2_observation=None, + trajectory_steps=steps, + error="budget_exhausted", + ) + + +# --------------------------------------------------------------------------- +# Stage a runs/ + results/ layout on disk +# --------------------------------------------------------------------------- + + +def stage_run( + tmp_root: Path, + *, + benchmark: str, + run_id: str, + framework: str = "claude_sdk", + mode: str = "a-interact", + query_mode: str = "slayer", + agent_model: str = "anthropic/claude-opus-4-7", + user_sim_model: str = "anthropic/claude-sonnet-4-6", + instances: list[tuple[str, str, dict[str, Any]]] | None = None, + started_at: str = "2026-06-10T10:00:00+00:00", +) -> tuple[Path, Path]: + """Lay out a run on disk that mirrors what the cloud merge produces. + + Each ``instances`` entry is ``(database, instance_id, trajectory_obj)``. + Returns ``(runs_root, results_root)`` so the caller can monkeypatch + ``paths.runs_root`` / ``paths.results_root`` to point at them. + """ + instances = instances or [] + runs_root = tmp_root / "runs" + results_root = tmp_root / "results" + runs_root.mkdir(parents=True, exist_ok=True) + results_root.mkdir(parents=True, exist_ok=True) + + for db, inst_id, traj in instances: + inst_dir = runs_root / benchmark / db / inst_id + inst_dir.mkdir(parents=True, exist_ok=True) + (inst_dir / f"{run_id}.trajectory.json").write_text(json.dumps(traj)) + (inst_dir / f"{run_id}.json").write_text( + json.dumps( + { + "schema_version": 1, + "kind": "submission_annotation", + "instance_id": inst_id, + "selected_database": db, + "submission": { + "cloud_run_id": run_id, + "duration_s": traj.get("duration_s", 12.3), + }, + } + ) + ) + + db_dir = results_root / benchmark / "cloud" / run_id + db_dir.mkdir(parents=True, exist_ok=True) + db_path = db_dir / "results.db" + con = sqlite3.connect(db_path) + con.execute( + """ + CREATE TABLE task_results ( + run_id TEXT, framework TEXT, mode TEXT, query_mode TEXT, + instance_id TEXT, database TEXT, started_at TEXT, + duration_s REAL, phase1_passed INTEGER, phase2_passed INTEGER, + total_reward REAL, submitted_sql TEXT, submitted_query TEXT, + ground_truth_sql TEXT, error TEXT, usage_json TEXT, user_query TEXT, + submission_status TEXT, + phase1_observation TEXT, phase2_observation TEXT, + predicted_result_json TEXT, gold_result_json TEXT, + n_agent_turns INTEGER, tool_call_stats_json TEXT, + phase1_observation_audited TEXT, phase1_observation_original TEXT + ) + """ + ) + con.execute( + """ + CREATE TABLE run_metadata ( + run_id TEXT, framework TEXT, mode TEXT, + agent_model TEXT, user_sim_model TEXT, started_at TEXT + ) + """ + ) + con.execute( + "INSERT INTO run_metadata VALUES (?, ?, ?, ?, ?, ?)", + (run_id, framework, mode, agent_model, user_sim_model, started_at), + ) + for db, inst_id, traj in instances: + con.execute( + """INSERT INTO task_results + (run_id, framework, mode, query_mode, + instance_id, database, started_at, duration_s, + phase1_passed, phase2_passed, total_reward, + submitted_sql, submitted_query, ground_truth_sql, + error, usage_json, submission_status, + phase1_observation, phase2_observation, + predicted_result_json, gold_result_json, + n_agent_turns) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + run_id, + framework, + mode, + query_mode, + inst_id, + db, + started_at, + traj.get("duration_s", 12.3), + int(traj["phase1_passed"]), + int(traj["phase2_passed"]), + traj.get("total_reward", 0.0), + traj["submitted_sql"], + traj.get("submitted_query", traj["submitted_sql"]), + traj.get("ground_truth_sql", ""), + traj.get("error"), + json.dumps(traj.get("usage", {})), + traj.get("submission_status", "error"), + traj.get("phase1_observation"), + traj.get("phase2_observation"), + traj.get("predicted_result_json"), + traj.get("gold_result_json"), + traj.get("n_agent_turns", 0), + ), + ) + con.commit() + con.close() + return runs_root, results_root diff --git a/tests/reports/conftest.py b/tests/reports/conftest.py new file mode 100644 index 00000000..748af178 --- /dev/null +++ b/tests/reports/conftest.py @@ -0,0 +1,55 @@ +"""Pytest fixtures for the bird_interact_agents.reports test suite (DEV-1553). + +The builder helpers live in ``tests/reports/_fixtures.py`` so tests can +import them directly without going through pytest's fixture system. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.reports._fixtures import stage_run + + +# --------------------------------------------------------------------------- +# Deterministic offline tokenizer +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_count_tokens(monkeypatch): + """Monkeypatch reports.tokens.count_tokens with a deterministic char-based fake. + + Returns ``max(1, len(s) // 4)`` so tests can land exactly on the + 250/1000 thresholds by sizing the string. Never contacts Anthropic. + """ + + def _fake(s: str, *, model: str = "claude-haiku-4-5-20251001") -> int: + return max(1, len(s) // 4) + + from bird_interact_agents.reports import tokens as _tokens + + monkeypatch.setattr(_tokens, "count_tokens", _fake) + return _fake + + +# --------------------------------------------------------------------------- +# Stage a synthetic run on disk + rewire the paths roots +# --------------------------------------------------------------------------- + + +@pytest.fixture +def stage(tmp_path: Path, monkeypatch): + """Return a callable that stages a run and rewires paths.runs_root / + paths.results_root to the staged tmp directory.""" + from bird_interact_agents import paths + + def _do(**kwargs) -> tuple[Path, Path]: + runs_root, results_root = stage_run(tmp_path, **kwargs) + monkeypatch.setattr(paths, "runs_root", lambda: runs_root) + monkeypatch.setattr(paths, "results_root", lambda: results_root) + return runs_root, results_root + + return _do diff --git a/tests/reports/test_action_canonicalize.py b/tests/reports/test_action_canonicalize.py new file mode 100644 index 00000000..ceb22f19 --- /dev/null +++ b/tests/reports/test_action_canonicalize.py @@ -0,0 +1,144 @@ +"""Tests for the MCP-tool-name → upstream-canonical action-string mapping. + +Spec (DEV-1553): +* bird-interact-tools wrappers map to upstream-canonical names: ``ask``, + ``submit``, ``execute``, ``get_schema``, ``get_all_column_meanings``, + ``get_column_meaning``, ``get_all_external_knowledge_names``, + ``get_knowledge_definition``, ``get_all_knowledge_definitions``. +* Unknown MCP tools fall through to ``()``. +* The mapping is the SINGLE source of truth for the leaderboard's + Section VI cost classifier (``ask``/``submit``/``execute`` are fixed). +""" + +from __future__ import annotations + +import json + +import pytest + + +# --------------------------------------------------------------------------- +# Upstream-canonical mapping +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "tool_name,tool_input,expected_action", + [ + # Submit-query — SQL is captured from `query_json` (slayer mode pipes + # the canonicalized SQL into the wrapper) or from `query` (raw mode). + ( + "mcp__bird-interact-tools__submit_query", + {"query_json": "SELECT 1"}, + "submit(SELECT 1)", + ), + ( + "mcp__bird-interact-tools__submit_query", + {"query": "SELECT 2"}, + "submit(SELECT 2)", + ), + # Execute-sql (raw mode only) + ( + "mcp__bird-interact-tools__execute_sql", + {"query": "SELECT * FROM t"}, + "execute(SELECT * FROM t)", + ), + # ask_user — exposed both as a top-level tool AND under the + # mcp__bird-interact-tools__ prefix (the SLayer agent wires it + # through the bird-interact-tools MCP server). Both canonicalise + # to ``ask()`` so they pick up the Section VI fixed + # cost of 2. + ("ask_user", {"question": "what is X?"}, "ask(what is X?)"), + ( + "mcp__bird-interact-tools__ask_user", + {"question": "what is X?"}, + "ask(what is X?)", + ), + # Zero-arg helpers + ( + "mcp__bird-interact-tools__get_schema", + {}, + "get_schema()", + ), + ( + "mcp__bird-interact-tools__get_all_column_meanings", + {}, + "get_all_column_meanings()", + ), + ( + "mcp__bird-interact-tools__get_all_external_knowledge_names", + {}, + "get_all_external_knowledge_names()", + ), + ( + "mcp__bird-interact-tools__get_all_knowledge_definitions", + {}, + "get_all_knowledge_definitions()", + ), + # Arg-bearing helpers — args are serialized as compact json.dumps. + ( + "mcp__bird-interact-tools__get_column_meaning", + {"table_name": "users", "column_name": "id"}, + 'get_column_meaning({"table_name":"users","column_name":"id"})', + ), + ( + "mcp__bird-interact-tools__get_knowledge_definition", + {"name": "kb_007"}, + 'get_knowledge_definition({"name":"kb_007"})', + ), + ], +) +def test_canonicalize_known_tool(tool_name, tool_input, expected_action): + from bird_interact_agents.reports.action_canonicalize import ( + canonicalize_action, + ) + + assert canonicalize_action(tool_name, tool_input) == expected_action + + +# --------------------------------------------------------------------------- +# Unknown MCP tools — fall through to raw () +# --------------------------------------------------------------------------- + + +def test_canonicalize_unknown_tool_passes_through(): + from bird_interact_agents.reports.action_canonicalize import ( + canonicalize_action, + ) + + result = canonicalize_action( + "mcp__slayer__search", {"entities": ["x"], "max_memories": 0} + ) + assert result.startswith("mcp__slayer__search(") + args_str = result[len("mcp__slayer__search(") : -1] + assert json.loads(args_str) == {"entities": ["x"], "max_memories": 0} + + +def test_canonicalize_unknown_zero_arg_tool(): + from bird_interact_agents.reports.action_canonicalize import ( + canonicalize_action, + ) + + assert canonicalize_action("mcp__slayer__list_datasources", {}) == ( + "mcp__slayer__list_datasources({})" + ) + + +# --------------------------------------------------------------------------- +# Codex finding #7 — Section VI uses prose names ``ask_user`` / ``submit_sql`` +# / ``execute_sql`` while upstream eval_react uses the short forms +# ``ask`` / ``submit`` / ``execute``. We pick the upstream short form and +# expose the mapping for documentation. +# --------------------------------------------------------------------------- + + +def test_section_vi_action_names_mapping_documented(): + from bird_interact_agents.reports.action_canonicalize import ( + SECTION_VI_NAME_TO_CANONICAL, + ) + + assert SECTION_VI_NAME_TO_CANONICAL == { + "ask_user": "ask", + "submit_sql": "submit", + "execute_sql": "execute", + } diff --git a/tests/reports/test_adapter_claude_sdk_otf.py b/tests/reports/test_adapter_claude_sdk_otf.py new file mode 100644 index 00000000..8a06c5af --- /dev/null +++ b/tests/reports/test_adapter_claude_sdk_otf.py @@ -0,0 +1,329 @@ +"""Tests for the Claude Agent SDK trajectory → Turn iterator. + +Spec (DEV-1553): +* Adapter consumes the SDK-native message stream + (``SystemMessage``/``AssistantMessage``/``UserMessage``/``ResultMessage``) + and yields one ``Turn`` per ``tool_use`` block. +* Pure-text/thinking assistant messages WITHOUT a tool_use fold into the + NEXT tool-using turn — they never appear as a no-op row. +* ``UserMessage.tool_result`` content is paired with the Turn that emitted + the matching ``tool_use_id``. +* Initial task statement (first ``UserMessage`` text before any + ``AssistantMessage``) is exposed as the ``prompt`` for turn 0. +""" + +from __future__ import annotations + +from tests.reports._fixtures import ( + assistant_msg, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, +) + + +def _walk(steps): + from bird_interact_agents.reports.adapters.claude_sdk_otf import ( + walk_trajectory, + ) + + return list(walk_trajectory(steps)) + + +# --------------------------------------------------------------------------- +# Basic shape +# --------------------------------------------------------------------------- + + +def test_walk_emits_one_turn_per_tool_use(): + steps = [ + system_msg(), + user_text_msg(text="Task statement."), + assistant_msg( + text="Calling submit.", + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": "SELECT 1"}, + ), + ), + tool_result_msg( + tool_use_id="tu_1", + content="Phase 1 SQL Correct! No Phase 2. Task finished.", + ), + ] + turns = _walk(steps) + assert len(turns) == 1 + t = turns[0] + assert t.tool_name == "mcp__bird-interact-tools__submit_query" + assert t.tool_input == {"query_json": "SELECT 1"} + assert t.tool_use_id == "tu_1" + assert "Phase 1 SQL Correct" in t.observation + assert t.model == "claude-opus-4-7" + + +def test_walk_first_turn_prompt_is_initial_task_text(): + steps = [ + system_msg(), + user_text_msg(text="Find rows where x = 1."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__get_schema", + inp={}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="schema text"), + ] + turns = _walk(steps) + assert turns[0].prompt == "Find rows where x = 1." + + +# --------------------------------------------------------------------------- +# Pure-text/thinking assistant message folds into next tool-using turn +# --------------------------------------------------------------------------- + + +def test_walk_folds_pure_text_into_next_turn(): + steps = [ + system_msg(), + user_text_msg(text="Task."), + # Pure-text assistant message (no tool_use) — must be folded. + assistant_msg(thinking="thinking aloud", text="I'll ask the user."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="ask_user", + inp={"question": "What does 'X' mean?"}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="It means foo."), + ] + turns = _walk(steps) + assert len(turns) == 1 + t = turns[0] + assert "thinking aloud" in t.response_raw + assert "I'll ask the user." in t.response_raw + # The tool_use's own text+thinking from this assistant message is also + # captured. + assert t.tool_name == "ask_user" + + +# --------------------------------------------------------------------------- +# Multi-content tool_result (list-of-text-blocks vs plain string) +# --------------------------------------------------------------------------- + + +def test_walk_tool_result_with_text_block_list_is_concatenated(): + """SDK can deliver tool_result content as a list of {type:text, text:...} + blocks instead of a plain string. The adapter must concatenate.""" + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__get_schema", + inp={}, + ), + ), + # Hand-craft a UserMessage whose tool_result content is a list. + { + "type": "UserMessage", + "data": { + "content": [ + { + "tool_use_id": "tu_1", + "type": "tool_result", + "content": [ + {"type": "text", "text": "table_a:\n col_x int\n"}, + {"type": "text", "text": "table_b:\n col_y text\n"}, + ], + } + ], + "uuid": "u", + "parent_tool_use_id": None, + "tool_use_result": None, + }, + }, + ] + turns = _walk(steps) + assert "table_a" in turns[0].observation + assert "table_b" in turns[0].observation + + +# --------------------------------------------------------------------------- +# Turn prompt = concatenation of intervening tool_results + free user text +# --------------------------------------------------------------------------- + + +def test_walk_turn_prompt_concatenates_intervening_user_inputs(): + """The prompt for turn N is the new text observed since turn N-1's + tool_use. That includes the matched tool_result AND any free + UserMessage text that arrived before the next tool_use.""" + steps = [ + system_msg(), + user_text_msg(text="initial."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__get_schema", + inp={}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="schema X"), + # Free user-sim turn between tool_use #1 and tool_use #2. + user_text_msg(text="Now also consider Y."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_2", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": "SELECT * FROM x JOIN y"}, + ), + ), + tool_result_msg( + tool_use_id="tu_2", content="Phase 1 SQL Correct! Task finished." + ), + ] + turns = _walk(steps) + assert len(turns) == 2 + assert turns[0].prompt == "initial." + # Turn 2's prompt = tool_result of tu_1 + free user text. + assert "schema X" in turns[1].prompt + assert "Now also consider Y." in turns[1].prompt + + +# --------------------------------------------------------------------------- +# response_raw preserves thinking + text + tool_use JSON +# --------------------------------------------------------------------------- + + +def test_walk_multiple_tool_uses_in_one_message_share_prompt_and_context(): + """Codex round 7: a single AssistantMessage can carry multiple + ``tool_use`` blocks. Every emitted Turn must share the SAME prompt + + thinking + text context (the model produced them all together); + resetting state after the first tool_use would leave subsequent + Turns with an empty prompt and missing context.""" + steps = [ + system_msg(), + user_text_msg(text="Initial task statement."), + # One assistant message, three content items: + # [thinking, tool_use A, tool_use B]. + { + "type": "AssistantMessage", + "data": { + "content": [ + {"thinking": "reasoning across both tools", "signature": "sig"}, + tool_use_block( + tool_use_id="tu_A", + name="mcp__bird-interact-tools__get_schema", + inp={}, + ), + tool_use_block( + tool_use_id="tu_B", + name="mcp__bird-interact-tools__get_all_column_meanings", + inp={}, + ), + ], + "model": "claude-opus-4-7", + "parent_tool_use_id": None, + "error": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + "message_id": "msg_dual", + "stop_reason": "tool_use", + "session_id": "sess_dual", + }, + }, + tool_result_msg(tool_use_id="tu_A", content="schema text"), + tool_result_msg(tool_use_id="tu_B", content="column meanings text"), + ] + turns = _walk(steps) + assert len(turns) == 2 + # BOTH turns carry the same prompt (the initial task statement). + assert turns[0].prompt == "Initial task statement." + assert turns[1].prompt == "Initial task statement." + # BOTH turns carry the thinking content (rendered into response_raw + # — structural envelope check, not content assertion). + assert "thinking" in turns[0].response_raw + assert "thinking" in turns[1].response_raw + # Each turn gets the correct observation. + assert "schema" in turns[0].observation + assert "column meanings" in turns[1].observation + # Different tool names + ids. + assert turns[0].tool_use_id == "tu_A" + assert turns[1].tool_use_id == "tu_B" + + +def test_walk_response_raw_preserves_thinking_and_tool_use(): + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + thinking="let me think...", + text="Calling submit.", + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": "SELECT 1"}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="Phase 1 SQL Correct!"), + ] + turns = _walk(steps) + raw = turns[0].response_raw + assert "let me think..." in raw + assert "Calling submit." in raw + assert "submit_query" in raw + assert "SELECT 1" in raw + + +# --------------------------------------------------------------------------- +# Adapter registry +# --------------------------------------------------------------------------- + + +def test_adapter_registry_accepts_real_cloud_metadata(): + """Real cloud runs persist ``framework='claude_sdk'`` (the CLI flag), + not the internal class name. The adapter must resolve that combo.""" + from bird_interact_agents.reports.adapters import get_adapter + + walker = get_adapter("claude_sdk", query_mode="slayer") + assert callable(walker) + + +def test_adapter_registry_covers_slayer_mode_internal_names(): + """Forward-compat: internal agent names also resolve (in case a future + run_metadata schema promotes them to persisted fields).""" + from bird_interact_agents.reports.adapters import get_adapter + + family = ( + "claude_sdk", + "claude_sdk_otf", + "claude_sdk_otf_ainteract", + ) + adapters = [get_adapter(f, query_mode="slayer") for f in family] + assert all(a is adapters[0] for a in adapters) + + +def test_adapter_registry_unknown_framework_errors(): + import pytest + + from bird_interact_agents.reports.adapters import get_adapter + + with pytest.raises((KeyError, ValueError)): + get_adapter("pydantic_ai", query_mode="slayer") + + +def test_adapter_registry_rejects_raw_query_mode(): + """``query_mode='raw'`` runs persist trajectory `data` as a Python + repr STRING, not a dict — the SLayer walker would crash. Until a + string-repr parser lands the lookup must error clearly so the + failure surfaces at source-resolution time (not mid-walk with a + confusing AttributeError).""" + import pytest + + from bird_interact_agents.reports.adapters import get_adapter + + with pytest.raises((KeyError, ValueError)): + get_adapter("claude_sdk", query_mode="raw") diff --git a/tests/reports/test_budget.py b/tests/reports/test_budget.py new file mode 100644 index 00000000..2a04fba8 --- /dev/null +++ b/tests/reports/test_budget.py @@ -0,0 +1,118 @@ +"""Tests for the harness-faithful budget calculation + Section VI cost replay. + +Spec (DEV-1553): +* ``total_budget`` for a-Interact = ``harness.calculate_budget(task_data, + patience, mode="a-interact") = 6 + 2*ambiguity_count + 2*patience``. +* Section VI costs are summed cumulatively over the trajectory in submit + order; ``remaining_budget[k] = max(0, total_budget - sum_{i<=k} + action_cost[i])``. +* The maximum is clipped at 0 (matches harness runtime ``update_budget``). +""" + +from __future__ import annotations + + +# --------------------------------------------------------------------------- +# total_budget matches harness.calculate_budget exactly +# --------------------------------------------------------------------------- + + +def test_total_budget_zero_ambiguity_patience3(): + from bird_interact_agents.reports.budget import calculate_total_budget + + task_data = {"user_query_ambiguity": {}, "knowledge_ambiguity": []} + # 6 + 2*0 + 2*3 + assert calculate_total_budget(task_data, patience=3) == 12.0 + + +def test_total_budget_critical_ambiguity_counted(): + from bird_interact_agents.reports.budget import calculate_total_budget + + task_data = { + "user_query_ambiguity": {"critical_ambiguity": ["a", "b"]}, + "knowledge_ambiguity": [], + } + # 6 + 2*2 + 2*3 + assert calculate_total_budget(task_data, patience=3) == 16.0 + + +def test_total_budget_knowledge_ambiguity_counted(): + from bird_interact_agents.reports.budget import calculate_total_budget + + task_data = { + "user_query_ambiguity": {}, + "knowledge_ambiguity": [{"id": 1}, {"id": 2}, {"id": 3}], + } + # 6 + 2*3 + 2*3 + assert calculate_total_budget(task_data, patience=3) == 18.0 + + +def test_total_budget_both_kinds_of_ambiguity(): + from bird_interact_agents.reports.budget import calculate_total_budget + + task_data = { + "user_query_ambiguity": {"critical_ambiguity": ["a"]}, + "knowledge_ambiguity": [{"id": 1}, {"id": 2}, {"id": 3}], + } + # 6 + 2*4 + 2*500 + assert calculate_total_budget(task_data, patience=500) == 1014.0 + + +def test_total_budget_matches_harness_calculate_budget(): + """Pin to the harness implementation so a future change is intentional.""" + from bird_interact_agents.harness import calculate_budget as harness_calc + from bird_interact_agents.reports.budget import calculate_total_budget + + for patience in (3, 500): + for crit in ([], ["a"], ["a", "b", "c", "d"]): + for kb_amb_n in (0, 1, 5): + task_data = { + "user_query_ambiguity": {"critical_ambiguity": crit}, + "knowledge_ambiguity": [{"id": i} for i in range(kb_amb_n)], + } + expected = harness_calc( + task_data, patience=patience, mode="a-interact" + ) + got = calculate_total_budget(task_data, patience=patience) + assert got == expected, ( + patience, + len(crit), + kb_amb_n, + expected, + got, + ) + + +# --------------------------------------------------------------------------- +# Cumulative Section VI replay over a turn list +# --------------------------------------------------------------------------- + + +def test_replay_monotone_decreasing_clipped_at_zero(): + from bird_interact_agents.reports.budget import replay_remaining_budget + + action_costs = [3.0, 2.0, 1.0, 0.5, 0.5, 0.5] + remaining = replay_remaining_budget( + total_budget=6.0, action_costs=action_costs + ) + # Cumulative: 3, 5, 6, 6.5, 7, 7.5 → remaining: 3, 1, 0, 0, 0, 0 + assert remaining == [3.0, 1.0, 0.0, 0.0, 0.0, 0.0] + # Monotonically non-increasing. + for a, b in zip(remaining, remaining[1:]): + assert a >= b + + +def test_replay_zero_actions_returns_empty(): + from bird_interact_agents.reports.budget import replay_remaining_budget + + assert replay_remaining_budget(total_budget=12.0, action_costs=[]) == [] + + +def test_replay_never_negative(): + from bird_interact_agents.reports.budget import replay_remaining_budget + + remaining = replay_remaining_budget( + total_budget=4.0, action_costs=[3.0, 3.0, 3.0] + ) + assert all(r >= 0.0 for r in remaining) + assert remaining[-1] == 0.0 diff --git a/tests/reports/test_converter.py b/tests/reports/test_converter.py new file mode 100644 index 00000000..54ddde23 --- /dev/null +++ b/tests/reports/test_converter.py @@ -0,0 +1,508 @@ +"""End-to-end converter tests: trajectory + results.db → SubmissionRow. + +Spec (DEV-1553) tests #5 (adapter integration), #6 (phase SQL extraction), +#7 (end-to-end), #14 (results.db cross-check warning), #18 (--no-thinking). +""" + +from __future__ import annotations + +from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + trajectory_no_submits, + trajectory_one_phase_pass, + trajectory_phase1_retry_then_phase2, + trajectory_two_phase_pass, + user_text_msg, +) + + +def _convert(traj_obj, *, task_data=None, patience=3, include_thinking=True): + """Test helper — discards the warnings list (a few tests below assert + on it explicitly via ``_convert_with_warnings``).""" + row, _ = _convert_with_warnings( + traj_obj, + task_data=task_data, + patience=patience, + include_thinking=include_thinking, + ) + return row + + +def _convert_with_warnings( + traj_obj, *, task_data=None, patience=3, include_thinking=True +): + from bird_interact_agents.reports.converter import build_submission_row + + return build_submission_row( + trajectory_obj=traj_obj, + framework="claude_sdk", + agent_model="anthropic/claude-opus-4-7", + user_sim_model="anthropic/claude-sonnet-4-6", + task_data=task_data or {"user_query_ambiguity": {}, "knowledge_ambiguity": []}, + patience=patience, + include_thinking=include_thinking, + ) + + +# --------------------------------------------------------------------------- +# Phase SQL extraction +# --------------------------------------------------------------------------- + + +def test_phase_sql_one_phase_pass(fake_count_tokens): + traj = trajectory_one_phase_pass(sql="SELECT 1", instance_id="alien_1") + row = _convert(traj) + assert row.instance_id == "alien_1" + assert row.subtask_1_predicted_sql == ["SELECT 1"] + assert row.subtask_2_predicted_sql == [] + + +def test_phase_sql_two_phase_pass(fake_count_tokens): + traj = trajectory_two_phase_pass( + phase1_sql="SELECT 1", phase2_sql="SELECT 2", instance_id="alien_2" + ) + row = _convert(traj) + assert row.subtask_1_predicted_sql == ["SELECT 1"] + assert row.subtask_2_predicted_sql == ["SELECT 2"] + + +def test_phase_sql_retry_takes_final_per_phase(fake_count_tokens): + """Phase-1 has 2 submits (wrong then right) — the RIGHT one wins.""" + traj = trajectory_phase1_retry_then_phase2( + phase1_wrong_sql="SELECT 999", + phase1_right_sql="SELECT 1", + phase2_sql="SELECT 2", + ) + row = _convert(traj) + assert row.subtask_1_predicted_sql == ["SELECT 1"] + assert row.subtask_2_predicted_sql == ["SELECT 2"] + + +def test_phase_sql_no_submits_both_empty(fake_count_tokens): + traj = trajectory_no_submits(instance_id="alien_4") + row = _convert(traj) + assert row.subtask_1_predicted_sql == [] + assert row.subtask_2_predicted_sql == [] + + +def test_phase_sql_slayer_mode_uses_trajectory_submitted_sql(fake_count_tokens): + """Codex round 4: SLayer-mode submits carry JSON DSL in `query_json`, + NOT compiled SQL. The leaderboard needs the SQL. For the LAST submit + we extract `trajectory.submitted_sql` (the server's compiled SQL).""" + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + compiled_sql = "SELECT cto_final.trans_method FROM cto_final" + slayer_dsl = '{"source_model": "cto_final", "dimensions": ["trans_method"]}' + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": slayer_dsl}, + ), + ), + tool_result_msg( + tool_use_id="tu_1", + content="Phase 1 SQL Correct! (Reward: 1 points). No Phase 2. Task finished.", + ), + ] + traj = build_trajectory( + instance_id="alien_5", + trajectory_steps=steps, + submitted_sql=compiled_sql, + submission_status="passed_phase1", + ) + row = _convert(traj) + # subtask_1_predicted_sql carries the COMPILED SQL, not the DSL. + assert row.subtask_1_predicted_sql == [compiled_sql] + # The `action` field still records what the agent literally called + # (the JSON DSL is the audit trail of the call). + assert slayer_dsl in row.prompt_flow[0].action + + +def test_converter_uses_trusted_instance_id_when_supplied(fake_count_tokens): + """Codex round 9: the converter accepts a trusted ``instance_id`` + from the caller (CLI source resolution) and emits it instead of + blindly trusting the trajectory payload.""" + traj = trajectory_one_phase_pass(instance_id="stamped_in_trajectory") + from bird_interact_agents.reports.converter import build_submission_row + + row, warnings = build_submission_row( + trajectory_obj=traj, + framework="claude_sdk", + agent_model="anthropic/claude-opus-4-7", + user_sim_model="anthropic/claude-sonnet-4-6", + task_data={"user_query_ambiguity": {}, "knowledge_ambiguity": []}, + patience=3, + instance_id="trusted_from_results_db", + ) + assert row.instance_id == "trusted_from_results_db" + # Mismatch surfaces as a warning so the operator sees the gap. + assert any("mismatches" in w.lower() for w in warnings), warnings + + +def test_converter_no_warning_when_trajectory_id_matches(fake_count_tokens): + traj = trajectory_one_phase_pass(instance_id="alien_1") + from bird_interact_agents.reports.converter import build_submission_row + + row, warnings = build_submission_row( + trajectory_obj=traj, + framework="claude_sdk", + agent_model="anthropic/claude-opus-4-7", + user_sim_model="anthropic/claude-sonnet-4-6", + task_data={"user_query_ambiguity": {}, "knowledge_ambiguity": []}, + patience=3, + instance_id="alien_1", + ) + assert row.instance_id == "alien_1" + assert not any("mismatches" in w.lower() for w in warnings) + + +def test_phase_sql_slayer_nested_dag_array_uses_compiled_sql(fake_count_tokens): + """Codex round 5: SLayer ``submit_query.query_json`` accepts + nested-DAG ARRAYS as well as single objects. Arrays starting with + ``[`` must also route through the compiled-SQL extraction path.""" + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + compiled_sql = "SELECT a FROM (SELECT * FROM x) sub" + dag_array = ( + '[{"name": "stage1", "source_model": "x", "dimensions": ["a"]}, ' + '{"name": "root", "source_model": "stage1", "dimensions": ["a"]}]' + ) + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": dag_array}, + ), + ), + tool_result_msg( + tool_use_id="tu_1", + content="Phase 1 SQL Correct! No Phase 2. Task finished.", + ), + ] + traj = build_trajectory( + instance_id="alien_dag", + trajectory_steps=steps, + submitted_sql=compiled_sql, + ) + row = _convert(traj) + # MUST be the COMPILED SQL, not the JSON DAG array literal. + assert row.subtask_1_predicted_sql == [compiled_sql] + assert dag_array not in str(row.subtask_1_predicted_sql) + + +def test_phase_sql_slayer_two_phase_recovers_per_phase_from_generated_sql( + fake_count_tokens, +): + """Codex round 10: real harness embeds compiled SQL in EVERY submit + observation via ``Generated SQL:\\n\\n\\nResult: ...`` (see + `_submit.py:806`). The converter parses it so both phases recover + their compiled SQL, NOT just the final one.""" + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + phase1_compiled = "SELECT a FROM x" + phase2_compiled = "SELECT a, b FROM x" + phase1_dsl = '{"source_model": "x", "dimensions": ["a"]}' + phase2_dsl = '{"source_model": "x", "dimensions": ["a", "b"]}' + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_p1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase1_dsl}, + ), + ), + tool_result_msg( + tool_use_id="tu_p1", + content=( + f"Generated SQL:\n{phase1_compiled}\n\n" + "Result: Phase 1 SQL Correct! Moving to Phase 2." + ), + ), + user_text_msg(text="Follow-up."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_p2", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase2_dsl}, + ), + ), + tool_result_msg( + tool_use_id="tu_p2", + content=( + f"Generated SQL:\n{phase2_compiled}\n\n" + "Result: Phase 2 SQL Correct! Task finished." + ), + ), + ] + traj = build_trajectory( + instance_id="alien_6", + trajectory_steps=steps, + submitted_sql=phase2_compiled, + phase1_passed=True, + phase2_passed=True, + ) + row, warnings = _convert_with_warnings(traj) + assert row.subtask_1_predicted_sql == [phase1_compiled] + assert row.subtask_2_predicted_sql == [phase2_compiled] + # No "not recoverable" warning — we recovered both. + assert not any("could not be recovered" in w for w in warnings) + + +def test_phase_sql_slayer_two_phase_falls_back_when_marker_missing( + fake_count_tokens, +): + """Legacy fixtures whose submit observations lack the ``Generated + SQL:`` prefix still recover the FINAL phase's SQL from + ``trajectory.submitted_sql`` and emit a manifest warning for the + lost earlier phase.""" + from tests.reports._fixtures import ( + assistant_msg, + build_trajectory, + system_msg, + tool_result_msg, + tool_use_block, + user_text_msg, + ) + + phase1_dsl = '{"source_model": "x", "dimensions": ["a"]}' + phase2_dsl = '{"source_model": "x", "dimensions": ["a", "b"]}' + phase2_compiled = "SELECT a, b FROM x" + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_p1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase1_dsl}, + ), + ), + # No `Generated SQL:` prefix on this observation. + tool_result_msg( + tool_use_id="tu_p1", + content="Phase 1 SQL Correct! Moving to Phase 2.", + ), + user_text_msg(text="Follow-up."), + assistant_msg( + tool_use=tool_use_block( + tool_use_id="tu_p2", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": phase2_dsl}, + ), + ), + tool_result_msg( + tool_use_id="tu_p2", + content="Phase 2 SQL Correct! Task finished.", + ), + ] + traj = build_trajectory( + instance_id="alien_6", + trajectory_steps=steps, + submitted_sql=phase2_compiled, + phase1_passed=True, + phase2_passed=True, + ) + row, warnings = _convert_with_warnings(traj) + assert row.subtask_1_predicted_sql == [""] + assert row.subtask_2_predicted_sql == [phase2_compiled] + assert any("could not be recovered" in w for w in warnings), warnings + + +# --------------------------------------------------------------------------- +# prompt_flow shape +# --------------------------------------------------------------------------- + + +def test_prompt_flow_one_entry_per_tool_use(fake_count_tokens): + traj = trajectory_phase1_retry_then_phase2() + row = _convert(traj) + # 3 submit_query calls. + assert len(row.prompt_flow) == 3 + actions = [e.action for e in row.prompt_flow] + assert all(a.startswith("submit(") for a in actions) + + +def test_prompt_flow_carries_model_and_user_sim_per_step(fake_count_tokens): + traj = trajectory_one_phase_pass() + row = _convert(traj) + e = row.prompt_flow[0] + assert e.model == "anthropic/claude-opus-4-7" + assert e.user_simulator == "anthropic/claude-sonnet-4-6" + + +def test_prompt_flow_carries_action_costs_for_submit(fake_count_tokens): + """``submit`` is a fixed-cost action; Section VI says cost = 3.""" + traj = trajectory_one_phase_pass() + row = _convert(traj) + assert row.prompt_flow[0].action == "submit(SELECT 1)" + assert row.prompt_flow[0].action_cost == 3 + + +def test_prompt_flow_remaining_budget_is_replayed(fake_count_tokens): + """remaining_budget = max(0, total_budget - cum_section_vi).""" + traj = trajectory_two_phase_pass() + # total_budget with 0 amb, patience=3 = 12. Two submits cost 3+3=6. + row = _convert(traj) + rb = [e.remaining_budget for e in row.prompt_flow] + assert rb == [9.0, 6.0] + + +def test_prompt_flow_token_count_uses_fake(fake_count_tokens): + """``action_input_tokens`` / ``action_output_tokens`` come from the + fake count_tokens (len // 4) so tests are deterministic.""" + traj = trajectory_one_phase_pass(sql="SELECT 1") + row = _convert(traj) + e = row.prompt_flow[0] + # action_args_string for submit = the SQL string "SELECT 1" (len 8) → 2 + assert e.action_input_tokens == 2 + # action_output_tokens = len(observation) // 4 → some positive count + assert e.action_output_tokens >= 1 + + +# --------------------------------------------------------------------------- +# include_thinking flag (Codex finding #3 / spec --no-thinking) +# --------------------------------------------------------------------------- + + +def _traj_with_thinking(): + """A trajectory whose one assistant turn has thinking + text + tool_use. + The literal token ``"thinking"`` appears in the rendered response iff + the thinking block survives rendering — that's the structural envelope + we assert on (avoids prompt-content assertion).""" + steps = [ + system_msg(), + user_text_msg(text="Task."), + assistant_msg( + thinking="reasoning aloud", + text="visible text", + tool_use=tool_use_block( + tool_use_id="tu_1", + name="mcp__bird-interact-tools__submit_query", + inp={"query_json": "SELECT 1"}, + ), + ), + tool_result_msg(tool_use_id="tu_1", content="Phase 1 SQL Correct!"), + ] + return build_trajectory(trajectory_steps=steps, submitted_sql="SELECT 1") + + +def test_include_thinking_true_keeps_thinking_envelope(fake_count_tokens): + row = _convert(_traj_with_thinking(), include_thinking=True) + # Structural envelope check: the rendered response carries a "thinking" + # marker (the SDK content-block type label) when thinking is preserved. + assert "thinking" in row.prompt_flow[0].response + + +def test_include_thinking_false_strips_thinking_envelope(fake_count_tokens): + row = _convert(_traj_with_thinking(), include_thinking=False) + # No thinking envelope when stripped. + assert "thinking" not in row.prompt_flow[0].response + # Tool-use payload still present in the rendered response. + assert "submit_query" in row.prompt_flow[0].response + + +def test_include_thinking_default_is_true(fake_count_tokens): + """Confirm the converter's default keeps thinking blocks.""" + # No include_thinking kwarg — must default to True per spec. + from bird_interact_agents.reports.converter import build_submission_row + + row, _warnings = build_submission_row( + trajectory_obj=_traj_with_thinking(), + framework="claude_sdk", + agent_model="anthropic/claude-opus-4-7", + user_sim_model="anthropic/claude-sonnet-4-6", + task_data={"user_query_ambiguity": {}, "knowledge_ambiguity": []}, + patience=3, + ) + assert "thinking" in row.prompt_flow[0].response + + +# --------------------------------------------------------------------------- +# results.db cross-check warning (Codex finding #6 — last-submit only) +# --------------------------------------------------------------------------- + + +def test_results_db_mismatch_yields_warning(fake_count_tokens): + """When the trajectory's last submit differs from results.db's stored + ``submitted_sql``, the converter records a warning. Not a hard error.""" + traj = trajectory_two_phase_pass(phase1_sql="A", phase2_sql="B") + # Inject a deliberate mismatch. + results_db_submitted_sql = "C" # neither phase-1 nor phase-2 SQL + from bird_interact_agents.reports.converter import ( + cross_check_results_db_sql, + ) + + row = _convert(traj) + warnings = cross_check_results_db_sql( + row=row, results_db_submitted_sql=results_db_submitted_sql + ) + assert len(warnings) >= 1 + assert any("mismatch" in w.lower() for w in warnings) + + +def test_results_db_matching_yields_no_warning(fake_count_tokens): + traj = trajectory_two_phase_pass(phase1_sql="A", phase2_sql="B") + from bird_interact_agents.reports.converter import ( + cross_check_results_db_sql, + ) + + row = _convert(traj) + warnings = cross_check_results_db_sql( + row=row, results_db_submitted_sql="B" + ) + assert warnings == [] + + +def test_cross_check_uses_last_submit_only(fake_count_tokens): + """results.db stores only ONE submitted_sql. Earlier phase-1 retries + that differ from the DB string must NOT warn — only the final overall + submit is checked against the DB column.""" + traj = trajectory_phase1_retry_then_phase2( + phase1_wrong_sql="WRONG_PHASE1", + phase1_right_sql="RIGHT_PHASE1", + phase2_sql="FINAL_PHASE2", + ) + from bird_interact_agents.reports.converter import ( + cross_check_results_db_sql, + ) + + row = _convert(traj) + # Earlier wrong-phase-1 submit differs from DB-stored SQL; no warning. + warnings = cross_check_results_db_sql( + row=row, results_db_submitted_sql="FINAL_PHASE2" + ) + assert warnings == [] diff --git a/tests/reports/test_cost.py b/tests/reports/test_cost.py new file mode 100644 index 00000000..e57be714 --- /dev/null +++ b/tests/reports/test_cost.py @@ -0,0 +1,130 @@ +"""Tests for the Section VI Universal Cost Scheme. + +Spec (DEV-1553): +* Fixed-cost actions: ``ask = 2``, ``submit = 3``, ``execute = 1``. +* Token-aware actions: ``input < 250 AND output < 1000 → 0.5``; else ``1.0``. + AND-semantics MUST be preserved at the boundary (Codex finding #4 demands + contract-exact behavior). +* Cost classification depends on the *canonical* action name, not the raw + MCP tool name. Anything not in the fixed set is token-aware. +""" + +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Fixed-cost actions +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonical,expected", + [("ask", 2), ("submit", 3), ("execute", 1)], +) +def test_fixed_cost_actions(canonical, expected): + from bird_interact_agents.reports.cost import compute_action_cost + + # Token counts must be IGNORED for fixed actions. + assert compute_action_cost(canonical, input_tokens=9999, output_tokens=9999) == expected + assert compute_action_cost(canonical, input_tokens=0, output_tokens=0) == expected + + +# --------------------------------------------------------------------------- +# Token-aware actions: AND-semantics at the threshold boundary +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "in_toks,out_toks,expected", + [ + # Cheap quadrant: both strictly below. + (0, 0, 0.5), + (249, 999, 0.5), + # Crossing EITHER threshold → expensive. + (250, 999, 1.0), # input at threshold + (249, 1000, 1.0), # output at threshold + (250, 1000, 1.0), # both at threshold + # Far above thresholds. + (5000, 5000, 1.0), + ], +) +def test_token_aware_threshold_boundaries(in_toks, out_toks, expected): + from bird_interact_agents.reports.cost import compute_action_cost + + assert ( + compute_action_cost( + "mcp__slayer__search", + input_tokens=in_toks, + output_tokens=out_toks, + ) + == expected + ) + + +def test_unknown_canonical_action_is_token_aware_not_fixed(): + """Anything outside {ask, submit, execute} routes through the token rule.""" + from bird_interact_agents.reports.cost import compute_action_cost + + cheap = compute_action_cost("get_schema", input_tokens=0, output_tokens=10) + assert cheap == 0.5 + expensive = compute_action_cost( + "get_schema", input_tokens=0, output_tokens=5000 + ) + assert expensive == 1.0 + + +# --------------------------------------------------------------------------- +# Section VI prose example reproduction (paper Appendix J). +# --------------------------------------------------------------------------- + + +def test_section_vi_paper_example_cheap(): + """``get_the_first_n_table_schema(3)`` with in=4, out=400 → 0.5.""" + from bird_interact_agents.reports.cost import compute_action_cost + + assert ( + compute_action_cost( + "get_the_first_n_table_schema", + input_tokens=4, + output_tokens=400, + ) + == 0.5 + ) + + +def test_section_vi_paper_example_expensive(): + """``get_the_first_n_table_schema(10)`` with in=4, out=2500 → 1.0.""" + from bird_interact_agents.reports.cost import compute_action_cost + + assert ( + compute_action_cost( + "get_the_first_n_table_schema", + input_tokens=4, + output_tokens=2500, + ) + == 1.0 + ) + + +# --------------------------------------------------------------------------- +# Exposed cost-table constants (used in manifest.fixed_costs). +# --------------------------------------------------------------------------- + + +def test_fixed_costs_constant_matches_section_vi(): + from bird_interact_agents.reports.cost import FIXED_COSTS + + assert FIXED_COSTS == {"ask": 2, "submit": 3, "execute": 1} + + +def test_section_vi_thresholds_constant(): + from bird_interact_agents.reports.cost import SECTION_VI_THRESHOLDS + + assert SECTION_VI_THRESHOLDS == { + "input_tokens_lt": 250, + "output_tokens_lt": 1000, + "cheap_cost": 0.5, + "expensive_cost": 1.0, + } diff --git a/tests/reports/test_coverage.py b/tests/reports/test_coverage.py new file mode 100644 index 00000000..9b29efb8 --- /dev/null +++ b/tests/reports/test_coverage.py @@ -0,0 +1,104 @@ +"""Tests for split-coverage check. + +Spec (DEV-1553) + Codex finding #1: +* When ``--run-id`` is used WITHOUT ``--allow-partial``, the run's + instance set MUST equal the full benchmark split, else SystemExit. +* When ``--selection`` is used WITHOUT ``--allow-partial``, the union of + selected instance_ids MUST equal the full benchmark split, else + SystemExit (Codex finding #1). +* ``--allow-partial`` gates BOTH paths. +""" + +from __future__ import annotations + +import pytest + + +def _stub_split(monkeypatch, instance_ids: set[str]): + """Stub the benchmark split lookup so we don't need real data files.""" + from bird_interact_agents.reports import coverage as _cov + + monkeypatch.setattr( + _cov, "load_benchmark_instance_ids", lambda benchmark: instance_ids + ) + + +# --------------------------------------------------------------------------- +# Coverage check: matching set +# --------------------------------------------------------------------------- + + +def test_coverage_full_set_passes(monkeypatch): + from bird_interact_agents.reports.coverage import ( + assert_coverage_ok, + ) + + _stub_split(monkeypatch, {"alien_1", "alien_2"}) + # No exception — full set matches. + assert_coverage_ok( + benchmark="bird-interact-lite-exp", + present_instance_ids={"alien_1", "alien_2"}, + allow_partial=False, + ) + + +# --------------------------------------------------------------------------- +# Coverage check: missing instances +# --------------------------------------------------------------------------- + + +def test_coverage_smaller_set_without_allow_partial_aborts(monkeypatch): + from bird_interact_agents.reports.coverage import ( + IncompleteCoverageError, + assert_coverage_ok, + ) + + _stub_split(monkeypatch, {"alien_1", "alien_2", "alien_3"}) + with pytest.raises(IncompleteCoverageError) as exc_info: + assert_coverage_ok( + benchmark="bird-interact-lite-exp", + present_instance_ids={"alien_1"}, + allow_partial=False, + ) + msg = str(exc_info.value) + # The error lists every MISSING instance and points at --allow-partial. + assert "alien_2" in msg + assert "alien_3" in msg + assert "alien_1" not in msg + assert "allow-partial" in msg.lower() + + +def test_coverage_smaller_set_with_allow_partial_passes(monkeypatch): + from bird_interact_agents.reports.coverage import assert_coverage_ok + + _stub_split(monkeypatch, {"alien_1", "alien_2", "alien_3"}) + # Does not raise. + assert_coverage_ok( + benchmark="bird-interact-lite-exp", + present_instance_ids={"alien_1"}, + allow_partial=True, + ) + + +# --------------------------------------------------------------------------- +# Coverage check: extra instances (instance_id not in the benchmark) +# --------------------------------------------------------------------------- + + +def test_coverage_extra_instances_are_a_hard_error_always(monkeypatch): + """If a selection names an instance the benchmark doesn't recognise, + abort regardless of --allow-partial — that's a typo, not a partial + run.""" + from bird_interact_agents.reports.coverage import ( + UnknownInstanceError, + assert_coverage_ok, + ) + + _stub_split(monkeypatch, {"alien_1", "alien_2"}) + with pytest.raises(UnknownInstanceError) as exc_info: + assert_coverage_ok( + benchmark="bird-interact-lite-exp", + present_instance_ids={"alien_1", "alien_2", "alien_typo"}, + allow_partial=True, + ) + assert "alien_typo" in str(exc_info.value) diff --git a/tests/reports/test_leakage.py b/tests/reports/test_leakage.py new file mode 100644 index 00000000..960df336 --- /dev/null +++ b/tests/reports/test_leakage.py @@ -0,0 +1,87 @@ +"""Tests for ``--check-leakage`` diagnostic (Codex finding #2 fold-in). + +Spec (DEV-1553): +* The leakage check scans every prompt_flow entry's ``prompt`` field for + case-insensitive substring matches against the instance's + ``ground_truth_sql``. The leakage counter for the instance is the + number of entries that contain ANY non-trivial substring of the gold + SQL. +* Does NOT redact — only reports a count into ``manifest.leakage_check``. +* Below a minimum substring length (default 12 chars) matches are + ignored, so short fragments like ``SELECT`` or ``FROM x`` do not + produce false positives. +""" + +from __future__ import annotations + + +def test_leakage_check_zero_when_clean(): + """Observation contains schema text only — gold SQL not present.""" + from bird_interact_agents.reports.leakage import count_leakage + + n = count_leakage( + prompts=["table_a:\n col_x int\n", "Phase 1 SQL Correct!"], + ground_truth_sql="SELECT trader.id FROM trader JOIN compliancecase ON x = y", + min_substring=12, + ) + assert n == 0 + + +def test_leakage_check_flags_full_gold_substring(): + """An observation containing the gold SQL verbatim flags as 1.""" + from bird_interact_agents.reports.leakage import count_leakage + + gold = "SELECT trader.id FROM trader JOIN compliancecase ON x = y" + n = count_leakage( + prompts=[f"Hint from user: try `{gold}`."], + ground_truth_sql=gold, + min_substring=12, + ) + assert n == 1 + + +def test_leakage_check_counts_per_prompt(): + from bird_interact_agents.reports.leakage import count_leakage + + gold = "SELECT trader.id FROM trader JOIN compliancecase ON x = y" + n = count_leakage( + prompts=[ + "schema info, no leak", + f"first leak: {gold}", + "more schema", + f"second leak: {gold[:30]}", # partial but ≥ min_substring + ], + ground_truth_sql=gold, + min_substring=12, + ) + assert n == 2 + + +def test_leakage_check_ignores_short_fragments(): + """Common SQL fragments (under min_substring) must not flag.""" + from bird_interact_agents.reports.leakage import count_leakage + + n = count_leakage( + prompts=["SELECT", "FROM x", "JOIN y", "WHERE z = 1"], + ground_truth_sql="SELECT * FROM x JOIN y ON x.k = y.k WHERE z = 1", + min_substring=12, + ) + assert n == 0 + + +def test_leakage_check_handles_missing_gold(): + """No ground_truth_sql → 0, no error.""" + from bird_interact_agents.reports.leakage import count_leakage + + assert ( + count_leakage( + prompts=["anything"], ground_truth_sql=None, min_substring=12 + ) + == 0 + ) + assert ( + count_leakage( + prompts=["anything"], ground_truth_sql="", min_substring=12 + ) + == 0 + ) diff --git a/tests/reports/test_output.py b/tests/reports/test_output.py new file mode 100644 index 00000000..ce49a53a --- /dev/null +++ b/tests/reports/test_output.py @@ -0,0 +1,258 @@ +"""Tests for the submission output writer. + +Spec (DEV-1553) tests #10 (email title), #11 (manifest), #15 (JSONL +schema validity). +""" + +from __future__ import annotations + +import json + +import pytest + + +# --------------------------------------------------------------------------- +# Email title (Section I) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "benchmark,expected_split", + [ + ("bird-interact-lite-exp", "lite"), + ("bird-interact-full", "full"), + ("mini-interact", "mini-interact"), + ], +) +def test_email_title_string(benchmark, expected_split): + from bird_interact_agents.reports.output import build_email_title + + title = build_email_title( + benchmark=benchmark, + setting="a-Interact", + team="Motley", + method="SLayer-Agent", + ) + assert ( + title + == f"[BIRD-INTERACT-1.0-{expected_split}][a-Interact][Motley][SLayer-Agent]" + ) + + +def test_email_title_unsupported_benchmark_raises(): + from bird_interact_agents.reports.output import build_email_title + + with pytest.raises(ValueError): + build_email_title( + benchmark="livesqlbench-base-lite-sqlite", + setting="a-Interact", + team="Motley", + method="SLayer-Agent", + ) + + +# --------------------------------------------------------------------------- +# Write submission directory +# --------------------------------------------------------------------------- + + +def _stub_row(instance_id: str, phase1_sql: str = "SELECT 1"): + from bird_interact_agents.reports.schema import PromptFlowEntry, SubmissionRow + + return SubmissionRow( + instance_id=instance_id, + subtask_1_predicted_sql=[phase1_sql], + subtask_2_predicted_sql=[], + prompt_flow=[ + PromptFlowEntry( + model="anthropic/claude-opus-4-7", + user_simulator="anthropic/claude-sonnet-4-6", + prompt="Task.", + response="Submitting.", + action=f"submit({phase1_sql})", + remaining_budget=9.0, + action_input_tokens=2, + action_output_tokens=8, + action_cost=3, + ) + ], + ) + + +def test_write_submission_creates_three_artefacts(tmp_path): + from bird_interact_agents.reports.output import ( + ManifestPlan, + write_submission, + ) + + rows = [_stub_row("alien_1"), _stub_row("alien_2", phase1_sql="SELECT 2")] + plan = ManifestPlan( + benchmark="bird-interact-lite-exp", + setting="a-Interact", + split="lite", + team="Motley", + method="SLayer-Agent", + tag="run-xyz", + selection_mode="run-id", + source_run_ids=["run-xyz"], + generated_at="2026-06-10T16:42:13+00:00", + instances=[ + { + "instance_id": "alien_1", + "run_id": "run-xyz", + "framework": "claude_sdk_otf", + "agent_model": "anthropic/claude-opus-4-7", + "user_sim_model": "anthropic/claude-sonnet-4-6", + "trajectory_path": "/dev/null", + "results_db_path": "/dev/null", + }, + { + "instance_id": "alien_2", + "run_id": "run-xyz", + "framework": "claude_sdk_otf", + "agent_model": "anthropic/claude-opus-4-7", + "user_sim_model": "anthropic/claude-sonnet-4-6", + "trajectory_path": "/dev/null", + "results_db_path": "/dev/null", + }, + ], + patience_resolution=[ + {"instance_id": "alien_1", "patience": 3, "source": "default"}, + {"instance_id": "alien_2", "patience": 3, "source": "default"}, + ], + leakage_check=None, + warnings_by_instance=[], + ) + out_dir = write_submission(rows=rows, plan=plan, out_dir=tmp_path) + assert (out_dir / "submission.jsonl").exists() + assert (out_dir / "email_title.txt").exists() + assert (out_dir / "manifest.json").exists() + + +# --------------------------------------------------------------------------- +# JSONL row schema validity (Codex finding #8) +# --------------------------------------------------------------------------- + + +def test_submission_jsonl_row_has_required_fields_and_no_debug_step(tmp_path): + from bird_interact_agents.reports.output import ( + ManifestPlan, + write_submission, + ) + + rows = [_stub_row("alien_1")] + plan = ManifestPlan( + benchmark="bird-interact-lite-exp", + setting="a-Interact", + split="lite", + team="Motley", + method="SLayer-Agent", + tag="run-xyz", + selection_mode="run-id", + source_run_ids=["run-xyz"], + generated_at="2026-06-10T16:42:13+00:00", + instances=[], + patience_resolution=[], + leakage_check=None, + warnings_by_instance=[], + ) + out_dir = write_submission(rows=rows, plan=plan, out_dir=tmp_path) + + lines = (out_dir / "submission.jsonl").read_text().splitlines() + assert len(lines) == 1 + obj = json.loads(lines[0]) + + # Required a-Interact custom-agent fields (Section II). + assert set(obj.keys()) == { + "instance_id", + "subtask_1_predicted_sql", + "subtask_2_predicted_sql", + "prompt_flow", + } + # No c-Interact-only keys. + assert "debug_step_1" not in obj + assert "debug_step_2" not in obj + # List-of-strings type (literal-spec interpretation). + assert isinstance(obj["subtask_1_predicted_sql"], list) + assert all(isinstance(s, str) for s in obj["subtask_1_predicted_sql"]) + assert isinstance(obj["subtask_2_predicted_sql"], list) + # prompt_flow entry shape. + entry = obj["prompt_flow"][0] + required = { + "model", + "user_simulator", + "prompt", + "response", + "action", + "remaining_budget", + "action_input_tokens", + "action_output_tokens", + "action_cost", + } + assert required.issubset(entry.keys()) + # No c-Interact `debug_step` smuggled in. + assert not any(k.startswith("debug_step") for k in entry.keys()) + + +# --------------------------------------------------------------------------- +# Manifest provenance + constants +# --------------------------------------------------------------------------- + + +def test_manifest_records_provenance_and_constants(tmp_path): + from bird_interact_agents.reports.output import ( + ManifestPlan, + write_submission, + ) + + plan = ManifestPlan( + benchmark="bird-interact-lite-exp", + setting="a-Interact", + split="lite", + team="Motley", + method="SLayer-Agent", + tag="run-xyz", + selection_mode="selection-file", + source_run_ids=["run-a", "run-b"], + generated_at="2026-06-10T16:42:13+00:00", + instances=[ + { + "instance_id": "alien_1", + "run_id": "run-a", + "framework": "claude_sdk_otf", + "agent_model": "anthropic/claude-opus-4-7", + "user_sim_model": "anthropic/claude-sonnet-4-6", + "trajectory_path": "/dev/null", + "results_db_path": "/dev/null", + } + ], + patience_resolution=[ + {"instance_id": "alien_1", "patience": 3, "source": "default"} + ], + leakage_check=None, + warnings_by_instance=[], + ) + rows = [_stub_row("alien_1")] + out_dir = write_submission(rows=rows, plan=plan, out_dir=tmp_path) + + mf = json.loads((out_dir / "manifest.json").read_text()) + assert mf["schema_version"] == 1 + assert mf["kind"] == "bird_interact_submission_manifest" + assert mf["benchmark"] == "bird-interact-lite-exp" + assert mf["split"] == "lite" + assert mf["setting"] == "a-Interact" + assert mf["team"] == "Motley" + assert mf["method"] == "SLayer-Agent" + assert mf["n_instances"] == 1 + assert mf["selection_mode"] == "selection-file" + assert mf["source_run_ids"] == ["run-a", "run-b"] + assert mf["instances"][0]["instance_id"] == "alien_1" + assert mf["patience_resolution"][0]["patience"] == 3 + assert mf["section_vi_threshold"] == { + "input_tokens_lt": 250, + "output_tokens_lt": 1000, + "cheap_cost": 0.5, + "expensive_cost": 1.0, + } + assert mf["fixed_costs"] == {"ask": 2, "submit": 3, "execute": 1} + assert "anthropic" in mf["tokenizer"].lower() diff --git a/tests/reports/test_paths_reports_root.py b/tests/reports/test_paths_reports_root.py new file mode 100644 index 00000000..ebbf95d1 --- /dev/null +++ b/tests/reports/test_paths_reports_root.py @@ -0,0 +1,101 @@ +"""Tests for ``paths.reports_root()`` — worktree-safe by construction. + +Mirrors the contract of ``tests/test_cloud_paths_unchanged.py`` / +``tests/test_paths.py``: any worktree run resolves to the MAIN checkout's +``reports/`` so submission artifacts land in one place. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + + +# Helpers borrowed verbatim from tests/test_paths.py +def _init_repo(repo_dir: Path) -> None: + repo_dir.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "init", "-q", "-b", "main", str(repo_dir)], check=True + ) + (repo_dir / "README.md").write_text("test\n") + subprocess.run( + ["git", "-C", str(repo_dir), "add", "README.md"], check=True + ) + subprocess.run( + [ + "git", + "-C", + str(repo_dir), + "-c", + "user.email=t@example.invalid", + "-c", + "user.name=Test", + "commit", + "-q", + "-m", + "init", + ], + check=True, + ) + + +@pytest.fixture(autouse=True) +def _isolate_paths(monkeypatch): + from bird_interact_agents import paths + + paths._main_checkout_root_cached.cache_clear() + monkeypatch.delenv("BIRD_REPORTS_ROOT", raising=False) + yield + paths._main_checkout_root_cached.cache_clear() + + +def test_reports_root_anchored_at_main_checkout(tmp_path, monkeypatch): + """`reports_root()` resolves to /reports/ even when the + helper is called from a worktree.""" + from bird_interact_agents import paths + + main = tmp_path / "main" + _init_repo(main) + subprocess.run( + [ + "git", + "-C", + str(main), + "worktree", + "add", + "-b", + "feature-x", + str(tmp_path / "wt"), + ], + check=True, + ) + + # Pretend the importing module lives inside the worktree. + monkeypatch.setattr(paths, "_LOOKUP_DIR", tmp_path / "wt") + paths._main_checkout_root_cached.cache_clear() + + assert paths.reports_root() == main / "reports" + + +def test_reports_root_env_override_honored(tmp_path, monkeypatch): + from bird_interact_agents import paths + + override = tmp_path / "custom-reports" + monkeypatch.setenv("BIRD_REPORTS_ROOT", str(override)) + assert paths.reports_root() == override + + +def test_reports_root_default_creates_directory(tmp_path, monkeypatch): + """The default reports root is created lazily (mirrors runs_root).""" + from bird_interact_agents import paths + + main = tmp_path / "main" + _init_repo(main) + monkeypatch.setattr(paths, "_LOOKUP_DIR", main) + paths._main_checkout_root_cached.cache_clear() + + root = paths.reports_root() + assert root.exists() + assert root.is_dir() diff --git a/tests/reports/test_phase_split.py b/tests/reports/test_phase_split.py new file mode 100644 index 00000000..55c2d971 --- /dev/null +++ b/tests/reports/test_phase_split.py @@ -0,0 +1,154 @@ +"""Tests for the per-submit phase classifier. + +Spec (DEV-1553): +* The bird-interact-tools submit tool emits an observation with one of: + ``Phase 1 SQL Correct! …``, ``Phase 2 SQL Correct! …``, + ``Submitted SQL failed test case in Phase {1|2}. …`` (authoritative + phrasing from ``action_handler_sqlite``). +* Per-submit classification reads the verdict directly when present. +* Fallback (Codex finding #5 tightening): before the FIRST observed + phase-1 verdict, all submits are phase-1; after, all submits are + phase-2. Inconsistent markers (e.g. phase-2 marker before any phase-1 + marker) → manifest warning, never error. Zero markers across the run + + non-zero submits → manifest warning, treat all as phase-1. +""" + +from __future__ import annotations + + +def _classify(observation): + from bird_interact_agents.reports.phase_split import classify_submit_observation + + return classify_submit_observation(observation) + + +# --------------------------------------------------------------------------- +# Per-observation classifier +# --------------------------------------------------------------------------- + + +def test_classify_phase1_correct_moving_to_phase2(): + assert _classify( + "Phase 1 SQL Correct! (Reward: 1 points). Moving to Phase 2." + ) == ("phase1", "correct") + + +def test_classify_phase1_correct_no_phase2(): + assert _classify( + "Phase 1 SQL Correct! (Reward: 1 points). No Phase 2. Task finished." + ) == ("phase1", "correct") + + +def test_classify_phase2_correct(): + assert _classify( + "Phase 2 SQL Correct! (Reward: 1 points). Task finished." + ) == ("phase2", "correct") + + +def test_classify_phase1_wrong(): + assert _classify( + "Submitted SQL failed test case in Phase 1. Reason: row mismatch. Please try again." + ) == ("phase1", "wrong") + + +def test_classify_phase2_wrong(): + assert _classify( + "Submitted SQL failed test case in Phase 2. Reason: column mismatch." + ) == ("phase2", "wrong") + + +def test_classify_unknown_observation(): + """No marker → returns (None, None).""" + assert _classify("some unrelated observation text") == (None, None) + + +def test_classify_handles_dict_content(): + """Tool result content can arrive as a list of {type:text, text:…} dicts + (Anthropic SDK shape). The classifier normalises before matching.""" + obs = [{"type": "text", "text": "Phase 2 SQL Correct! Task finished."}] + assert _classify(obs) == ("phase2", "correct") + + +# --------------------------------------------------------------------------- +# Whole-trajectory walk +# --------------------------------------------------------------------------- + + +def test_split_phase_classifies_two_phase_pass(): + """Phase-1 right + phase-2 right → ordered phase labels.""" + from bird_interact_agents.reports.phase_split import split_phases + + observations = [ + "Phase 1 SQL Correct! Moving to Phase 2.", + "Phase 2 SQL Correct! Task finished.", + ] + result = split_phases(observations) + assert result.labels == ["phase1", "phase2"] + assert result.warnings == [] + + +def test_split_phase_retry_then_phase2(): + """Phase-1 wrong, phase-1 right (retry), phase-2 right.""" + from bird_interact_agents.reports.phase_split import split_phases + + observations = [ + "Submitted SQL failed test case in Phase 1. Reason: x.", + "Phase 1 SQL Correct! Moving to Phase 2.", + "Phase 2 SQL Correct! Task finished.", + ] + result = split_phases(observations) + assert result.labels == ["phase1", "phase1", "phase2"] + assert result.warnings == [] + + +def test_split_phase_no_markers_anywhere_warning(): + """Submits exist but no observation carries a phase marker → warning, + label all phase-1 (Codex finding #5 fallback).""" + from bird_interact_agents.reports.phase_split import split_phases + + observations = ["unrelated text", "another unrelated"] + result = split_phases(observations) + assert result.labels == ["phase1", "phase1"] + assert any("no phase markers" in w.lower() for w in result.warnings) + + +def test_split_phase_inconsistent_marker_order_warning(): + """Phase-2 marker before any phase-1 marker → warning, not error.""" + from bird_interact_agents.reports.phase_split import split_phases + + observations = [ + "Phase 2 SQL Correct! Task finished.", + "Phase 1 SQL Correct! Moving to Phase 2.", + ] + result = split_phases(observations) + # We surface the inconsistency; labels still come from the marker text. + assert result.labels == ["phase2", "phase1"] + assert any( + "phase-2 marker" in w.lower() and "before" in w.lower() + for w in result.warnings + ) + + +def test_split_phase_empty_input_is_clean(): + from bird_interact_agents.reports.phase_split import split_phases + + result = split_phases([]) + assert result.labels == [] + assert result.warnings == [] + + +def test_split_phase_phase1_wrong_then_phase2_correct_warns_no_phase1_success(): + """Submits go: phase-1 wrong, phase-2 correct (skipped phase-1 success). + Real runs can't produce this, but a corrupted trajectory could. + Per the spec the warning is reported; never error.""" + from bird_interact_agents.reports.phase_split import split_phases + + observations = [ + "Submitted SQL failed test case in Phase 1. Reason: x.", + "Phase 2 SQL Correct! Task finished.", + ] + result = split_phases(observations) + # Labels come from the markers directly. + assert result.labels == ["phase1", "phase2"] + # Some warning about the missing phase-1 success. + assert result.warnings, "expected at least one warning" diff --git a/tests/reports/test_selection.py b/tests/reports/test_selection.py new file mode 100644 index 00000000..3204c546 --- /dev/null +++ b/tests/reports/test_selection.py @@ -0,0 +1,261 @@ +"""Tests for selection.jsonl loading + per-instance source resolution. + +Spec (DEV-1553): +* Duplicate ``instance_id`` in a selection file is a hard error listing + every duplicate. +* Missing trajectory.json for any (instance_id, run_id) is a hard error + listing every missing entry. +* Stub-only trajectory (no ``trajectory`` array) is a hard error. +""" + +from __future__ import annotations + +import json + +import pytest + +from tests.reports._fixtures import trajectory_one_phase_pass + + +# --------------------------------------------------------------------------- +# Selection.jsonl loader +# --------------------------------------------------------------------------- + + +def test_selection_loads_well_formed_file(tmp_path): + from bird_interact_agents.reports.selection import load_selection + + sel_path = tmp_path / "selection.jsonl" + sel_path.write_text( + "\n".join( + [ + json.dumps({"instance_id": "alien_1", "run_id": "run_a"}), + json.dumps({"instance_id": "alien_2", "run_id": "run_b"}), + ] + ) + ) + sel = load_selection(sel_path) + assert sel == [ + ("alien_1", "run_a"), + ("alien_2", "run_b"), + ] + + +def test_selection_duplicate_instance_id_is_hard_error(tmp_path): + """Codex finding #1 + spec: dupes must be flagged with every duplicate + listed in the error message.""" + from bird_interact_agents.reports.selection import ( + DuplicateInstanceError, + load_selection, + ) + + sel_path = tmp_path / "selection.jsonl" + sel_path.write_text( + "\n".join( + [ + json.dumps({"instance_id": "alien_1", "run_id": "run_a"}), + json.dumps({"instance_id": "alien_1", "run_id": "run_b"}), + json.dumps({"instance_id": "alien_3", "run_id": "run_c"}), + json.dumps({"instance_id": "alien_3", "run_id": "run_d"}), + ] + ) + ) + with pytest.raises(DuplicateInstanceError) as exc_info: + load_selection(sel_path) + msg = str(exc_info.value) + assert "alien_1" in msg + assert "alien_3" in msg + + +def test_selection_malformed_line_is_hard_error(tmp_path): + from bird_interact_agents.reports.selection import load_selection + + sel_path = tmp_path / "selection.jsonl" + sel_path.write_text('{"instance_id": "alien_1"}\n') # missing run_id + with pytest.raises((ValueError, KeyError)): + load_selection(sel_path) + + +# --------------------------------------------------------------------------- +# Source resolution (locate trajectory.json + results.db) +# --------------------------------------------------------------------------- + + +def test_resolve_sources_finds_existing_trajectory(stage): + _runs_root, _results_root = stage( + benchmark="bird-interact-lite-exp", + run_id="r1", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + from bird_interact_agents.reports.sources import resolve_sources + + sources = resolve_sources( + selection=[("alien_1", "r1")], + benchmark="bird-interact-lite-exp", + ) + src = sources["alien_1"] + assert src.trajectory_path.exists() + assert src.results_db_path.exists() + assert src.database == "alien" + # Real cloud runs persist `framework="claude_sdk"` (the CLI flag). + assert src.framework == "claude_sdk" + assert src.agent_model == "anthropic/claude-opus-4-7" + assert src.user_sim_model == "anthropic/claude-sonnet-4-6" + assert src.mode == "a-interact" + assert src.query_mode == "slayer" + + +def test_resolve_sources_missing_trajectory_lists_every_missing(stage, tmp_path): + stage( + benchmark="bird-interact-lite-exp", + run_id="r1", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + from bird_interact_agents.reports.sources import ( + MissingTrajectoryError, + resolve_sources, + ) + + with pytest.raises(MissingTrajectoryError) as exc_info: + resolve_sources( + selection=[ + ("alien_1", "r1"), # present + ("alien_2", "r1"), # missing + ("alien_3", "r1"), # missing + ], + benchmark="bird-interact-lite-exp", + ) + msg = str(exc_info.value) + assert "alien_2" in msg + assert "alien_3" in msg + assert "alien_1" not in msg # the present one should not appear + + +def test_resolve_sources_stub_only_trajectory_is_hard_error(stage): + """A trajectory.json whose top-level lacks a ``trajectory`` array + (older mini-interact placeholder) cannot be reconstructed.""" + import json as _json + + runs_root, _ = stage( + benchmark="mini-interact", + run_id="r2", + instances=[ + ( + "db_a", + "db_a_0", + {"instance_id": "db_a_0", "phase1_passed": False, + "phase2_passed": False, "submitted_sql": "", + "submission_status": "error"}, + ), + ], + ) + # Overwrite the trajectory file with a stub (no `trajectory` array). + stub_path = runs_root / "mini-interact" / "db_a" / "db_a_0" / "r2.trajectory.json" + stub_path.write_text(_json.dumps({"trajectory_path": "rows/db_a_0/attempt-1.json"})) + + from bird_interact_agents.reports.sources import ( + StubTrajectoryError, + resolve_sources, + ) + + with pytest.raises(StubTrajectoryError) as exc_info: + resolve_sources( + selection=[("db_a_0", "r2")], + benchmark="mini-interact", + ) + assert "db_a_0" in str(exc_info.value) + + +def test_resolve_sources_missing_task_results_row_is_hard_error(stage): + """Codex round 3 finding: a trajectory.json + results.db that exist + but lack the selected instance_id in task_results must NOT silently + produce an InstanceSource with empty mode (which would bypass the + a-Interact gate). Refuse here.""" + import sqlite3 + + _runs_root, results_root = stage( + benchmark="bird-interact-lite-exp", + run_id="r1", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + # Drop the task_results row but keep the trajectory + run_metadata. + db = results_root / "bird-interact-lite-exp" / "cloud" / "r1" / "results.db" + con = sqlite3.connect(db) + con.execute("DELETE FROM task_results WHERE instance_id = ?", ("alien_1",)) + con.commit() + con.close() + + from bird_interact_agents.reports.sources import ( + MissingTaskResultsError, + resolve_sources, + ) + + with pytest.raises(MissingTaskResultsError) as exc_info: + resolve_sources( + selection=[("alien_1", "r1")], + benchmark="bird-interact-lite-exp", + ) + assert "alien_1" in str(exc_info.value) + + +def test_resolve_sources_duplicate_task_results_rows_hard_error(stage): + """Codex round 5: task_results' composite key includes + framework/mode/query_mode, so a corrupted DB could carry multiple + rows per (run_id, instance_id). The current code would silently + pick whichever row SQLite returned last. Detect + abort.""" + import sqlite3 + + _runs_root, results_root = stage( + benchmark="bird-interact-lite-exp", + run_id="r1", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + db = results_root / "bird-interact-lite-exp" / "cloud" / "r1" / "results.db" + # Inject a duplicate row with a different mode — simulates a + # contaminated DB. + con = sqlite3.connect(db) + con.execute( + "INSERT INTO task_results (run_id, instance_id, mode, query_mode, " + "framework, database, phase1_passed, phase2_passed) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ("r1", "alien_1", "one-shot", "raw", "claude_sdk", "alien", 0, 0), + ) + con.commit() + con.close() + + from bird_interact_agents.reports.sources import resolve_sources + + with pytest.raises(ValueError) as exc_info: + resolve_sources( + selection=[("alien_1", "r1")], + benchmark="bird-interact-lite-exp", + ) + assert "alien_1" in str(exc_info.value) + + +def test_resolve_sources_missing_results_db_is_hard_error(stage, tmp_path): + _runs_root, results_root = stage( + benchmark="bird-interact-lite-exp", + run_id="r1", + instances=[ + ("alien", "alien_1", trajectory_one_phase_pass(instance_id="alien_1")), + ], + ) + # Remove the results.db. + (results_root / "bird-interact-lite-exp" / "cloud" / "r1" / "results.db").unlink() + + from bird_interact_agents.reports.sources import resolve_sources + + with pytest.raises((FileNotFoundError, ValueError)): + resolve_sources( + selection=[("alien_1", "r1")], + benchmark="bird-interact-lite-exp", + ) diff --git a/tests/reports/test_tokens.py b/tests/reports/test_tokens.py new file mode 100644 index 00000000..19ec0d61 --- /dev/null +++ b/tests/reports/test_tokens.py @@ -0,0 +1,70 @@ +"""Tests for the token-counter wrapper. + +Spec (DEV-1553 + Codex finding #4): +* ``count_tokens(s)`` returns Anthropic's token count for ``s`` AFTER + subtracting a per-process baseline = ``count_tokens("")``, so the + Section VI 250/1000 thresholds are contract-exact (no wrapper bias). +* The function is module-level so tests can monkeypatch it. +* An LRU cache keyed on ``(hash(s), model)`` keeps the network call count + bounded when SQL strings repeat (retry submits). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + + +def test_count_tokens_subtracts_empty_message_baseline(monkeypatch): + """The wrapper subtracts the empty-message token count so that + ``count_tokens("")`` returns 0 and any short string returns the + intrinsic token count without the user-message envelope overhead.""" + from bird_interact_agents.reports import tokens as _tokens + + # Stub the underlying Anthropic-SDK call so the test is offline. + # The stub returns a fixed envelope of 4 tokens for empty content + 1 + # token per non-empty character (a deliberately weird shape to verify + # the baseline is subtracted, not approximated). + def _stub_api(messages, **_): + body = messages[0]["content"] + return MagicMock(input_tokens=4 + len(body)) + + monkeypatch.setattr(_tokens, "_count_tokens_via_api", _stub_api) + # Force the baseline to recompute. + _tokens._reset_baseline_cache() + + # Empty string → 0 reported (envelope subtracted). + assert _tokens.count_tokens("") == 0 + # A 7-char string → 7 reported (envelope 4 subtracted from raw 11). + assert _tokens.count_tokens("abcdefg") == 7 + + +def test_count_tokens_caches_repeated_calls(monkeypatch): + """Repeated calls for the same (string, model) hit cache, not the API.""" + from bird_interact_agents.reports import tokens as _tokens + + calls: list[str] = [] + + def _stub_api(messages, **_): + body = messages[0]["content"] + calls.append(body) + return MagicMock(input_tokens=4 + len(body)) + + monkeypatch.setattr(_tokens, "_count_tokens_via_api", _stub_api) + _tokens._reset_baseline_cache() + + s = "SELECT 1 FROM x" + a = _tokens.count_tokens(s) + b = _tokens.count_tokens(s) + assert a == b + # API hit: once for baseline, once for the string. Second call to + # count_tokens(s) MUST not hit the API again. + assert calls.count(s) == 1 + + +def test_count_tokens_function_is_module_level_for_monkeypatching(): + """The fake_count_tokens fixture relies on monkeypatching + ``reports.tokens.count_tokens`` directly.""" + from bird_interact_agents.reports import tokens as _tokens + + assert hasattr(_tokens, "count_tokens") + assert callable(_tokens.count_tokens)