Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

1826 lines
74 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Run a real, evidence-producing Experiment 7-12 T3A ablation.
This companion runner imports the adjacent, unmodified AndroidWorld checkout.
It supports a paired control/treatment experiment and a subsequent candidate
rerun. It never substitutes mock episodes or manuscript example numbers.
"""
from __future__ import annotations
import argparse
import copy
from datetime import datetime, timezone
import hashlib
import importlib
import json
import os
from pathlib import Path
import random
import re
import sqlite3
import subprocess
import sys
import tempfile
import time
from typing import Any
from experiment_core import (
BASELINE_TASK_COUNT,
WIFI_TASKS,
aggregate_episodes,
choose_decision,
choose_efficiency_decision,
dumps_json,
enforce_scope_claims,
paired_rows,
redact_text,
render_report,
)
DEFAULT_GUIDELINES = (
"For Wi-Fi tasks, first inspect the visible Settings state. If the requested "
"state is already achieved, immediately finish with status complete and do not toggle it.",
"If a change is needed, open Settings and use Network & internet, then Internet, "
"then the visible Wi-Fi switch. Wait for the UI to settle after toggling.",
"Before reporting completion, verify the requested on/off state from the visible "
"switch or status text. Do not repeatedly reopen Settings or toggle without checking state.",
)
HYPOTHESIS_DEFINITIONS = {
"H1": {
"layer": "surface",
"idea": "Add Wi-Fi Settings navigation and final-state verification guidance.",
"target": "At least one net paired success across the four Wi-Fi tasks, with no regression.",
"verification": "Paired upstream-prompt versus task-guideline ablation with matched seeds.",
"change": "Use upstream T3A.set_task_guidelines for only Wi-Fi navigation and verification.",
"phase": "phase_1_surface",
"phase_description": "low-cost surface prompt ablation",
"independent_variable": "task-specific T3A guidelines",
},
"H2": {
"layer": "surface",
"idea": "Add application-specific recognition rules for the non-standard Tasks UI.",
"target": "Improve at least two of the six historical Tasks failures with no regression.",
"verification": "Paired Tasks-only prompt/tool-description ablation after app provisioning.",
},
"H3": {
"layer": "middle",
"idea": "Repair and validate the multimodal input path for transcription tasks.",
"target": "Raise transcription success above the historical 0% while bounding added tokens and latency.",
"verification": "Paired screenshot-disabled versus screenshot-enabled transcription run.",
},
"H4": {
"layer": "middle",
"idea": "Conditionally enable deeper thinking for counting tasks.",
"target": "Improve math/counting success without applying the cost to unrelated tasks.",
"verification": "Paired tag-routed thinking-mode ablation with latency and token guardrails.",
},
"H5": {
"layer": "middle",
"idea": "Replace the API-35-incompatible gRPC accessibility feed with upstream's UIAutomator observation path.",
"target": "At least one net paired Wi-Fi success with no regression and at most 1.5x latency/tokens.",
"verification": "Paired a11y-forwarder versus UIAutomator run with the same upstream T3A prompt and matched seeds.",
"change": "Select AndroidWorld's UIAUTOMATOR observation method in the companion runner without changing upstream source.",
"phase": "phase_2_middle",
"phase_description": "middle-layer input-pipeline ablation prompted by phase-1 residual traces",
"independent_variable": "accessibility observation pipeline (gRPC forwarder versus UIAutomator)",
},
"H5C": {
"layer": "middle",
"idea": "Filter non-semantic UIAutomator container nodes after H5 exposed excessive prompt-token cost.",
"target": "Preserve H5 paired success with no regression while using at most 0.75x raw-UIAutomator tokens and 1.5x latency.",
"verification": "Paired raw-UIAutomator versus compact-UIAutomator run with matched tasks, seeds, prompt, and evaluator.",
"change": "Use the real upstream UIAutomator hierarchy but retain only visible text, descriptions, and actionable/scrollable elements.",
"phase": "phase_2_cost_refinement",
"phase_description": "middle-layer input-pipeline cost refinement after the H5 success/cost result",
"independent_variable": "raw versus semantic-filtered UIAutomator element list",
},
"H6": {
"layer": "deep",
"idea": "Combine screenshots with the structured UI tree and compare stronger vision-capable models.",
"target": "Improve complex-UI success enough to justify multimodal latency and token cost.",
"verification": "Factorial UI-tree/screenshot/model ablation on the full tagged slice.",
},
}
HYPOTHESIS_GUARDRAILS = {
"H1": (
"Same model, seed, task parameters, emulator, checkout, and step budget; "
"require at least four completed pairs, positive net success, no regression, "
"and at most 1.5x mean latency and tokens."
),
"H5": (
"Same model, seed, task parameters, emulator, checkout, and step budget; "
"require at least four completed pairs, positive net success, no regression, "
"and at most 1.5x mean latency and tokens."
),
"H5C": (
"Same model, seed, task parameters, emulator, checkout, and step budget; "
"require at least four completed pairs, every compact-UIAutomator treatment "
"pair successful, no paired regression, at most 1.5x mean latency, and at "
"most 0.75x raw-UIAutomator mean tokens."
),
}
SCOPE_CAVEAT = (
" Passing a paired gate permits only a full-suite candidate rerun; it is not "
"deployment approval, and a subset must never be reported as full-suite success."
)
EXPERIMENT_ID = "7-12"
LEGACY_EXPERIMENT_IDS = {"7-12"}
REQUIRED_APP_PACKAGES = {
"android world": "com.example.androidworld",
"audio recorder": "com.dimowner.audiorecorder",
"camera": "com.android.camera2",
"chrome": "com.android.chrome",
"clipper": "ca.zgrs.clipper",
"clock": "com.google.android.deskclock",
"contacts": "com.google.android.contacts",
"dialer": "com.google.android.dialer",
"files": "com.google.android.documentsui",
"joplin": "net.cozic.joplin",
"markor": "net.gsantner.markor",
"miniwob": "com.google.androidenv.miniwob",
"open tracks": "de.dennisguse.opentracks",
"osmand": "net.osmand",
"pro expense": "com.arduia.expense",
"recipe": "com.flauschcode.broccoli",
"retro music": "code.name.monkey.retromusic",
"settings": "com.android.settings",
"simple calendar pro": "com.simplemobiletools.calendar.pro",
"simple draw pro": "com.simplemobiletools.draw.pro",
"simple gallery pro": "com.simplemobiletools.gallery.pro",
"simple sms messenger": "com.simplemobiletools.smsmessenger",
"tasks": "org.tasks",
"vlc": "org.videolan.vlc",
}
_CONTEXT_LIMIT_ERROR = re.compile(
r"maximum context length is (\d+).*prompt contains at least (\d+)"
)
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _run(command: list[str], *, cwd: Path | None = None) -> str:
return subprocess.run(
command,
cwd=cwd,
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).stdout.strip()
def _adb(adb_path: str, console_port: int, *args: str) -> str:
return _run([adb_path, "-s", f"emulator-{console_port}", *args])
def _jsonable(value: Any) -> Any:
try:
json.dumps(value)
return value
except TypeError:
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
return str(value)
def _bootstrap_android_world(checkout: Path) -> dict[str, Any]:
"""Imports a source checkout while generating absent protobufs in /tmp."""
checkout = checkout.resolve()
if not (checkout / "android_world" / "registry.py").is_file():
raise FileNotFoundError(f"Not an AndroidWorld checkout: {checkout}")
sys.path.insert(0, str(checkout))
proto_package = importlib.import_module(
"android_world.task_evals.information_retrieval.proto"
)
generated = False
generated_root = None
try:
importlib.import_module(
"android_world.task_evals.information_retrieval.proto.state_pb2"
)
except ImportError:
from grpc_tools import protoc # Imported lazily for a precise setup error.
generated_root = Path(tempfile.mkdtemp(prefix="androidworld-proto-"))
proto_dir = checkout / "android_world/task_evals/information_retrieval/proto"
for filename in ("state.proto", "task.proto"):
rc = protoc.main([
"grpc_tools.protoc",
f"-I{checkout}",
f"--python_out={generated_root}",
str(proto_dir / filename),
])
if rc:
raise RuntimeError(f"grpc_tools.protoc failed for {filename}: exit {rc}")
generated_package = (
generated_root / "android_world/task_evals/information_retrieval/proto"
)
proto_package.__path__.insert(0, str(generated_package))
importlib.import_module(
"android_world.task_evals.information_retrieval.proto.state_pb2"
)
generated = True
return {
"generated_protobufs_in_temporary_directory": generated,
"temporary_directory_persisted": False,
}
def _read_evidence(path: Path, *, purpose: str) -> dict[str, Any]:
try:
evidence = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError(f"Could not read {purpose} evidence {path}: {error}") from error
if (
evidence.get("experiment") not in {EXPERIMENT_ID, *LEGACY_EXPERIMENT_IDS}
or not evidence.get("run_id")
):
raise RuntimeError(f"Invalid {purpose} evidence: not an Experiment 7-12 artifact")
return evidence
def _collect_app_provenance(adb_path: str, console_port: int) -> dict[str, Any]:
"""Records the exact official app-package state visible to the benchmark."""
installed_output = _adb(adb_path, console_port, "shell", "pm", "list", "packages")
installed_packages = {
line.removeprefix("package:").strip()
for line in installed_output.splitlines()
if line.startswith("package:")
}
rows = []
missing = []
for app_name, package_name in REQUIRED_APP_PACKAGES.items():
installed = package_name in installed_packages
if not installed:
missing.append(package_name)
row: dict[str, Any] = {
"app": app_name,
"package": package_name,
"installed": installed,
"version_code": None,
"version_name": None,
"apk_paths": [],
}
if installed:
package_dump = _adb(
adb_path, console_port, "shell", "dumpsys", "package", package_name
)
version_code = re.search(r"\bversionCode=(\d+)", package_dump)
version_name = re.search(r"\bversionName=([^\s]+)", package_dump)
row["version_code"] = int(version_code.group(1)) if version_code else None
row["version_name"] = version_name.group(1) if version_name else None
paths = _adb(adb_path, console_port, "shell", "pm", "path", package_name)
row["apk_paths"] = sorted(
line.removeprefix("package:").strip()
for line in paths.splitlines()
if line.startswith("package:")
)
rows.append(row)
return {
"source": "direct adb package-manager query at run start",
"required_package_count": len(REQUIRED_APP_PACKAGES),
"installed_required_package_count": len(REQUIRED_APP_PACKAGES) - len(missing),
"complete": not missing,
"missing_packages": missing,
"apps": rows,
}
def _context_safe_output_cap(message: str, requested: int) -> int | None:
"""Returns a one-token-headroom cap from an OpenAI-style context error."""
context_match = _CONTEXT_LIMIT_ERROR.search(message)
if not context_match:
return None
# vLLM's lower-bound estimate shifted by two tokens between identical
# retries in the observed Notes prompts. Reserve bounded headroom rather
# than chasing the moving lower bound one token at a time.
available = (
int(context_match.group(1))
- int(context_match.group(2))
- 32
)
return available if 0 < available < requested else None
def _truncate_current_ui_section(
prompt: str, *, max_ui_chars: int = 12000
) -> tuple[str, int] | None:
"""Truncates only middles of T3A action or summary UI sections."""
def compact_middle(
section: str, retained_chars: int, label: str
) -> tuple[str, int] | None:
if len(section) <= retained_chars:
return None
front_chars = retained_chars * 2 // 3
back_chars = retained_chars - front_chars
marker = (
f"\n[Middle {label} UI elements omitted after the model's native "
"context limit was reached; original element indices are preserved.]\n"
)
compact = section[:front_chars] + marker + section[-back_chars:]
return compact, len(section) - len(compact)
start_marker = (
"\n\nHere is a list of descriptions for some UI elements on the current"
" screen:\n"
)
end_marker = "\nHere are some useful guidelines you need to follow:\n"
start = prompt.find(start_marker)
if start >= 0:
start += len(start_marker)
end = prompt.find(end_marker, start)
if end >= 0:
result = compact_middle(prompt[start:end], max_ui_chars, "current-screen")
if result is not None:
compact, removed = result
return prompt[:start] + compact + prompt[end:], removed
before_marker = "Here is the description for the before screenshot:\n"
after_marker = "\nHere is the description for the after screenshot:\n"
action_marker = "\nThis is the action you picked:"
before_start = prompt.find(before_marker)
if before_start < 0:
return None
before_start += len(before_marker)
before_end = prompt.find(after_marker, before_start)
if before_end < 0:
return None
after_start = before_end + len(after_marker)
after_end = prompt.find(action_marker, after_start)
if after_end < 0:
return None
before_result = compact_middle(
prompt[before_start:before_end], max_ui_chars // 2, "before-screen"
)
after_result = compact_middle(
prompt[after_start:after_end], max_ui_chars // 2, "after-screen"
)
if before_result is None or after_result is None:
return None
compact_before, removed_before = before_result
compact_after, removed_after = after_result
compact_prompt = (
prompt[:before_start]
+ compact_before
+ after_marker
+ compact_after
+ prompt[after_end:]
)
return compact_prompt, removed_before + removed_after
def _install_uiautomator_evaluator_compatibility() -> dict[str, Any]:
"""Keeps the official contact-draft predicate usable without a gRPC forest."""
from android_world.task_evals import task_eval
from android_world.task_evals.single import contacts
original = contacts.ContactsNewContactDraft.is_successful
def is_successful(self: Any, env: Any) -> float:
state = env.get_state()
if state.forest is not None:
return original(self, env)
task_eval.TaskEval.is_successful(self, env)
return float(contacts._contact_info_is_entered( # pylint: disable=protected-access
ui_elements=state.ui_elements,
first=self.params["first"],
last=self.params["last"],
phone=self.params["phone"],
phone_label=self.params["phone_label"],
))
contacts.ContactsNewContactDraft.is_successful = is_successful
return {
"task": "ContactsNewContactDraft",
"reason": "The upstream evaluator assumes state.forest, which is None for UIAutomator.",
"change": (
"When forest is None, pass state.ui_elements to the evaluator's unchanged "
"_contact_info_is_entered predicate."
),
"official_predicate_preserved": True,
}
def _missing_retro_queue_as_empty(get_queue: Any) -> Any:
"""Maps the pinned Retro APK's absent queue schema to an empty observation."""
def compatible_get_queue(env: Any) -> list[str]:
try:
return get_queue(env)
except sqlite3.OperationalError as error:
if str(error) != "no such table: playing_queue":
raise
return []
return compatible_get_queue
def _read_nonempty_with_retry(
read: Any, *, attempts: int = 6, delay_s: float = 1.0
) -> list[Any]:
"""Polls an asynchronously populated Android content provider."""
if attempts < 1:
raise ValueError("attempts must be positive")
result: list[Any] = []
for attempt in range(attempts):
result = read()
if result or attempt == attempts - 1:
return result
time.sleep(delay_s)
return result
def _retry_clipper_foreground(call: Any, *args: Any, delay_s: float = 1.0) -> Any:
"""Retries once only for AndroidWorld's documented Clipper foreground race."""
try:
return call(*args)
except RuntimeError as error:
if not str(error).startswith(
"Clipper app must be in the foreground to access clipboard."
):
raise
time.sleep(delay_s)
return call(*args)
def _install_clipper_timing_compatibility() -> dict[str, Any]:
"""Retries the unchanged clipboard operation after a foreground race."""
from android_world.env import adb_utils
original_get = adb_utils.get_clipboard_contents
original_set = adb_utils.set_clipboard_contents
def get_clipboard_contents(env: Any) -> str:
return _retry_clipper_foreground(original_get, env)
def set_clipboard_contents(content: str, env: Any) -> None:
_retry_clipper_foreground(original_set, content, env)
adb_utils.get_clipboard_contents = get_clipboard_contents
adb_utils.set_clipboard_contents = set_clipboard_contents
return {
"task": "clipboard-dependent tasks",
"reason": (
"The official Clipper app intermittently returns its documented "
"foreground-access RuntimeError immediately after launch."
),
"change": (
"Retry the unchanged clipboard get/set operation once after one second, "
"only for the exact Clipper foreground-access error."
),
"task_data_and_evaluator_preserved": True,
}
def _install_sms_inbox_timing_compatibility() -> dict[str, Any]:
"""Waits for an emulator-injected SMS to appear before upstream indexes it."""
from android_world.env import adb_utils
from android_world.task_evals.single import sms
original = sms.SimpleSmsReplyMostRecent._get_received_messages # pylint: disable=protected-access
original_text_emulator = adb_utils.text_emulator
latest_injected: dict[int, tuple[str, str]] = {}
def text_emulator(
env: Any, phone_number: str, message: str, timeout_sec: float = 20.0
) -> Any:
response = original_text_emulator(env, phone_number, message, timeout_sec)
latest_injected[id(env)] = (phone_number, message)
return response
def get_received_messages(self: Any, env: Any) -> list[str]:
rows = _read_nonempty_with_retry(lambda: original(self, env))
if rows:
return rows
injected = latest_injected.get(id(env))
if injected is None:
return rows
phone_number, message = injected
address_hex = phone_number.encode("utf-8").hex()
body_hex = message.encode("utf-8").hex()
adb_utils.execute_sql_command(
"/data/data/com.android.providers.telephony/databases/mmssms.db",
(
"INSERT INTO sms(address,date,read,type,body,seen) VALUES("
f"CAST(X'{address_hex}' AS TEXT),{int(time.time() * 1000)},1,1,"
f"CAST(X'{body_hex}' AS TEXT),1);"
),
env,
)
return _read_nonempty_with_retry(
lambda: original(self, env), attempts=3, delay_s=1.0
)
adb_utils.text_emulator = text_emulator
sms.SimpleSmsReplyMostRecent._get_received_messages = get_received_messages # pylint: disable=protected-access
return {
"task": "SimpleSmsReplyMostRecent",
"reason": (
"The emulator inbox can remain empty after the upstream fixed five-second "
"wait; upstream documents the resulting list-index error in "
"scripts/run_suite_on_docker.py."
),
"change": (
"Poll the unchanged received-message query for five additional seconds; "
"if the emulator console still failed to populate the inbox, insert the "
"exact last injected address/body into the same SMS SQLite database that "
"upstream already clears directly, then rerun the unchanged query."
),
"task_data_and_evaluator_preserved": True,
}
def _install_retro_queue_evaluator_compatibility() -> dict[str, Any]:
"""Lets the official queue equality predicate score a known absent schema."""
from android_world.task_evals.single import retro_music
retro_music._get_playing_queue = _missing_retro_queue_as_empty( # pylint: disable=protected-access
retro_music._get_playing_queue # pylint: disable=protected-access
)
return {
"task": "RetroPlayingQueue",
"reason": (
"The pinned official Retro Music APK does not create the "
"music_playback_state.playing_queue table; upstream documents the same "
"runtime error in scripts/run_suite_on_docker.py."
),
"change": (
"Map only sqlite3.OperationalError('no such table: playing_queue') to an "
"empty observed queue, then retain the official exact queue comparison."
),
"official_predicate_preserved": True,
"missing_schema_scores_as_evaluator_failure": True,
}
def _validate_phase1_source(evidence: dict[str, Any]) -> None:
if evidence.get("hypothesis", {}).get("id") != "H1":
raise RuntimeError("Phase-2 H5 requires direct H1 phase-1 evidence")
if len(evidence.get("paired_comparison", [])) < 4:
raise RuntimeError("Phase-2 H5 requires at least four completed H1 pairs")
if evidence.get("decision", {}).get("promote_to_full_suite_candidate"):
raise RuntimeError("H5 is a residual-failure follow-up; the supplied H1 run was promoted")
def _validate_phase2_source(evidence: dict[str, Any]) -> None:
if evidence.get("hypothesis", {}).get("id") != "H5":
raise RuntimeError("H5C requires direct H5 phase-2 evidence")
if len(evidence.get("paired_comparison", [])) < 4:
raise RuntimeError("H5C requires at least four completed H5 pairs")
decision = evidence.get("decision", {})
if int(decision.get("net_success_delta", 0)) <= 0:
raise RuntimeError("H5C requires an H5 source with positive paired success gain")
if decision.get("outcome") != "restrict_candidate_due_to_cost":
raise RuntimeError("H5C is only justified when H5 was restricted by the cost guardrail")
pairs = evidence.get("paired_comparison", [])
if not all(row.get("treatment_success") is True for row in pairs):
raise RuntimeError("H5C requires H5 source treatment success on every source pair")
def _validate_candidate_source(evidence: dict[str, Any], hypothesis_id: str) -> None:
if evidence.get("hypothesis", {}).get("id") != hypothesis_id:
raise RuntimeError(
f"Candidate hypothesis {hypothesis_id} does not match paired source "
f"{evidence.get('hypothesis', {}).get('id')}"
)
decision = evidence.get("decision", {})
if len(evidence.get("paired_comparison", [])) < 4:
raise RuntimeError("Candidate rerun requires at least four completed source pairs")
if not decision.get("promote_to_full_suite_candidate"):
raise RuntimeError("Candidate rerun refused: paired source did not pass success/cost guardrails")
def _load_android_env(args: argparse.Namespace, a11y_method: str) -> Any:
"""Loads an upstream environment, selecting UIAutomator without editing upstream."""
from android_world.env import android_world_controller
from android_world.env import env_launcher
if a11y_method == "a11y_forwarder_app":
return env_launcher.load_and_setup_env(
console_port=args.console_port,
emulator_setup=args.perform_emulator_setup,
freeze_datetime=False,
adb_path=args.adb_path,
grpc_port=args.grpc_port,
)
from android_env import loader
from android_env.components import config_classes
from android_world.env import interface
config = config_classes.AndroidEnvConfig(
task=config_classes.FilesystemTaskConfig(
path=android_world_controller._write_default_task_proto() # pylint: disable=protected-access
),
simulator=config_classes.EmulatorConfig(
emulator_launcher=config_classes.EmulatorLauncherConfig(
emulator_console_port=args.console_port,
adb_port=args.console_port + 1,
grpc_port=args.grpc_port,
),
adb_controller=config_classes.AdbControllerConfig(adb_path=args.adb_path),
),
)
base_env = loader.load(config)
controller = android_world_controller.AndroidWorldController(
base_env,
a11y_method=android_world_controller.A11yMethod.UIAUTOMATOR,
install_a11y_forwarding_app=False,
)
if a11y_method == "uiautomator_compact":
original_get_ui_elements = controller.get_ui_elements
def get_compact_ui_elements() -> list[Any]:
elements = original_get_ui_elements()
return [
element for element in elements
if (
(element.text or "").strip()
or (element.content_description or "").strip()
or element.is_clickable
or element.is_checkable
or element.is_editable
or element.is_scrollable
or element.is_long_clickable
)
]
controller.get_ui_elements = get_compact_ui_elements
env = interface.AsyncAndroidEnv(controller)
env_launcher.setup_env(
env,
emulator_setup=args.perform_emulator_setup,
freeze_datetime=False,
)
return env
def _arm_a11y_method(args: argparse.Namespace, arm: str) -> str:
if args.hypothesis == "H5" and args.mode == "paired":
return "a11y_forwarder_app" if arm == "control" else "uiautomator"
if args.hypothesis == "H5" and args.mode == "candidate-rerun":
return "uiautomator"
if args.hypothesis == "H5C" and args.mode == "paired":
return "uiautomator" if arm == "control" else "uiautomator_compact"
if args.hypothesis == "H5C" and args.mode == "candidate-rerun":
return "uiautomator_compact"
return args.a11y_method
def _arm_guidelines(args: argparse.Namespace, arm: str, task_name: str) -> list[str]:
if args.hypothesis != "H1":
return []
if arm in ("treatment", "candidate"):
return _guidelines_for(task_name)
return []
class OpenAICompatibleLlm:
"""Minimal real API wrapper matching AndroidWorld's LlmWrapper contract."""
def __init__(
self,
*,
api_key: str,
base_url: str,
model: str,
seed: int,
max_tokens: int,
timeout_s: float,
retries: int,
input_cost_per_million_usd: float,
output_cost_per_million_usd: float,
):
from openai import OpenAI
self._client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout_s)
self.model = model
self.seed = seed
self.max_tokens = max_tokens
self.retries = retries
self.input_cost_per_million_usd = input_cost_per_million_usd
self.output_cost_per_million_usd = output_cost_per_million_usd
self.calls = 0
self.input_tokens = 0
self.output_tokens = 0
self.reasoning_tokens = 0
self.total_latency_s = 0.0
self.system_fingerprints: set[str] = set()
self.context_cap_adjustments = 0
self.context_prompt_truncations = 0
self.context_prompt_chars_removed = 0
def snapshot(self) -> dict[str, Any]:
return {
"calls": self.calls,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"reasoning_tokens": self.reasoning_tokens,
"estimated_cost_usd": round(
self.input_tokens * self.input_cost_per_million_usd / 1_000_000
+ self.output_tokens * self.output_cost_per_million_usd / 1_000_000,
9,
),
"latency_s": self.total_latency_s,
"system_fingerprints": sorted(self.system_fingerprints),
"context_cap_adjustments": self.context_cap_adjustments,
"context_prompt_truncations": self.context_prompt_truncations,
"context_prompt_chars_removed": self.context_prompt_chars_removed,
}
@staticmethod
def delta(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
return {
"calls": after["calls"] - before["calls"],
"input_tokens": after["input_tokens"] - before["input_tokens"],
"output_tokens": after["output_tokens"] - before["output_tokens"],
"reasoning_tokens": after["reasoning_tokens"] - before["reasoning_tokens"],
"estimated_cost_usd": round(
after["estimated_cost_usd"] - before["estimated_cost_usd"], 9
),
"latency_s": round(after["latency_s"] - before["latency_s"], 6),
"system_fingerprints": after["system_fingerprints"],
"context_cap_adjustments": (
after["context_cap_adjustments"] - before["context_cap_adjustments"]
),
"context_prompt_truncations": (
after["context_prompt_truncations"]
- before["context_prompt_truncations"]
),
"context_prompt_chars_removed": (
after["context_prompt_chars_removed"]
- before["context_prompt_chars_removed"]
),
}
def predict(self, text_prompt: str) -> tuple[str, None, Any]:
last_error = None
request_max_tokens = self.max_tokens
request_prompt = text_prompt
for attempt in range(1, self.retries + 1):
started = time.monotonic()
try:
response = self._client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": request_prompt}],
temperature=0,
seed=self.seed,
max_tokens=request_max_tokens,
)
self.total_latency_s += time.monotonic() - started
self.calls += 1
usage = response.usage
if usage is not None:
self.input_tokens += int(usage.prompt_tokens or 0)
self.output_tokens += int(usage.completion_tokens or 0)
details = getattr(usage, "completion_tokens_details", None)
self.reasoning_tokens += int(getattr(details, "reasoning_tokens", 0) or 0)
fingerprint = getattr(response, "system_fingerprint", None)
if fingerprint:
self.system_fingerprints.add(str(fingerprint))
content = response.choices[0].message.content
if not content:
raise RuntimeError("Model returned empty content")
return content, None, response
except Exception as error: # noqa: BLE001 - API surface is provider-specific.
self.total_latency_s += time.monotonic() - started
last_error = error
message = str(error)
available = _context_safe_output_cap(message, request_max_tokens)
if available is not None:
request_max_tokens = available
self.context_cap_adjustments += 1
continue
if _CONTEXT_LIMIT_ERROR.search(message):
truncation = _truncate_current_ui_section(request_prompt)
if truncation is not None:
request_prompt, removed = truncation
self.context_prompt_truncations += 1
self.context_prompt_chars_removed += removed
continue
if attempt < self.retries:
time.sleep(2 ** (attempt - 1))
raise RuntimeError(f"Real model API failed after {self.retries} attempts: {last_error}")
def _extract_json_object(text: str) -> dict[str, Any]:
start = text.find("{")
end = text.rfind("}")
if start < 0 or end <= start:
raise ValueError("model response did not contain a JSON object")
value = json.loads(text[start:end + 1])
if not isinstance(value, dict):
raise ValueError("model response was not a JSON object")
return value
def _bounded_string(value: Any, secrets: list[str], limit: int = 1200) -> str:
return redact_text(value, secrets).strip()[:limit]
def _generate_llm_analysis(
evidence: dict[str, Any],
llm: OpenAICompatibleLlm,
secrets: list[str],
) -> dict[str, Any]:
"""Asks the configured real LLM for a bounded analysis of direct evidence."""
episode_summary = [
{
"task": row.get("task"),
"arm": row.get("arm"),
"status": row.get("status"),
"initial_evaluator_reward": row.get("initial_evaluator_reward"),
"evaluator_reward": row.get("evaluator_reward"),
"agent_declared_done": row.get("agent_declared_done"),
"success": row.get("success"),
"steps": row.get("steps"),
"elapsed_s": row.get("elapsed_s"),
"llm_calls": row.get("llm", {}).get("calls"),
"total_tokens": int(row.get("llm", {}).get("input_tokens", 0))
+ int(row.get("llm", {}).get("output_tokens", 0)),
"error": row.get("error"),
}
for row in evidence.get("episodes", [])
]
episode_sample_strategy = "all episodes"
if len(episode_summary) > 200:
failures = [
row
for row in episode_summary
if row.get("status") != "completed" or row.get("success") is not True
]
episode_summary = failures[:100]
episode_sample_strategy = (
f"first {len(episode_summary)} negative/error episodes in canonical order; "
"aggregate metrics cover every episode"
)
analysis_input = {
"episode_count": len(evidence.get("episodes", [])),
"episode_sample_strategy": episode_sample_strategy,
"scope": evidence.get("scope"),
"hypothesis": evidence.get("hypothesis"),
"phase": evidence.get("phase"),
"arm_summary": evidence.get("arm_summary"),
"paired_comparison": evidence.get("paired_comparison"),
"decision": evidence.get("decision"),
"episodes": episode_summary,
"environment_boundaries": evidence.get("environment_boundaries"),
}
prompt = (
"Analyze the following real Experiment 7-12 AndroidWorld evidence. Return exactly one "
"JSON object with keys summary (string), observed_failure_pattern (array of strings), "
"cost_benefit_interpretation (string), and next_hypothesis (object with id, layer, idea, "
"target, verification). Do not invent results, extrapolate the four-task subset to 116 "
"tasks, approve deployment, or treat historical/hypothetical numbers as observed. Mention "
"the API/app boundaries when they affect interpretation. Evidence:\n"
+ json.dumps(analysis_input, ensure_ascii=False, sort_keys=True)
)
before = llm.snapshot()
try:
content, _, _ = llm.predict(prompt)
parsed = _extract_json_object(content)
required = {
"summary",
"observed_failure_pattern",
"cost_benefit_interpretation",
"next_hypothesis",
}
if not required.issubset(parsed):
raise ValueError(f"model JSON missing keys: {sorted(required - set(parsed))}")
patterns = parsed["observed_failure_pattern"]
next_hypothesis = parsed["next_hypothesis"]
if not isinstance(patterns, list) or not isinstance(next_hypothesis, dict):
raise ValueError("model JSON used invalid field types")
return {
"status": "completed",
"source": "real configured LLM over aggregate direct evidence",
"summary": _bounded_string(parsed["summary"], secrets),
"observed_failure_pattern": [
_bounded_string(item, secrets) for item in patterns[:8]
],
"cost_benefit_interpretation": _bounded_string(
parsed["cost_benefit_interpretation"], secrets
),
"next_hypothesis": {
key: _bounded_string(next_hypothesis.get(key, "n/a"), secrets)
for key in ("id", "layer", "idea", "target", "verification")
},
"llm": OpenAICompatibleLlm.delta(before, llm.snapshot()),
}
except Exception as error: # noqa: BLE001 - preserve a report-generation blocker.
return {
"status": "error",
"source": "real configured LLM over aggregate direct evidence",
"error": _bounded_string(error, secrets, 2000),
"llm": OpenAICompatibleLlm.delta(before, llm.snapshot()),
}
def _parse_args() -> argparse.Namespace:
here = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--android-world-checkout",
type=Path,
default=here.parent / "android_world",
)
parser.add_argument("--mode", choices=("paired", "candidate-rerun"), default="paired")
parser.add_argument("--hypothesis", choices=("H1", "H5", "H5C"), default="H1")
parser.add_argument("--tasks", default=",".join(WIFI_TASKS))
parser.add_argument("--full-suite", action="store_true")
parser.add_argument("--trials", type=int, default=1)
parser.add_argument(
"--trial-indices",
help="Optional comma-separated 1-based trial shard; --trials still declares total scope.",
)
parser.add_argument("--execution-shard")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--model-seed", type=int, default=42)
parser.add_argument("--max-steps", type=int, default=10)
parser.add_argument("--transition-pause", type=float, default=None)
parser.add_argument("--provider", default="ark")
parser.add_argument("--model", default="doubao-seed-1-6-250615")
parser.add_argument("--base-url", default="https://ark.cn-beijing.volces.com/api/v3")
parser.add_argument("--api-key-env", default="ARK_API_KEY")
parser.add_argument("--max-model-tokens", type=int, default=1024)
parser.add_argument("--model-timeout-s", type=float, default=90.0)
parser.add_argument("--model-retries", type=int, default=3)
parser.add_argument("--input-cost-per-million-usd", type=float, default=0.0)
parser.add_argument("--output-cost-per-million-usd", type=float, default=0.0)
parser.add_argument("--model-source", default="hosted_api")
parser.add_argument("--model-revision")
parser.add_argument("--model-runtime")
parser.add_argument("--accelerator")
parser.add_argument(
"--adb-path",
default=str(Path.home() / "Library/Android/sdk/platform-tools/adb"),
)
parser.add_argument("--console-port", type=int, default=5554)
parser.add_argument("--grpc-port", type=int, default=8554)
parser.add_argument("--perform-emulator-setup", action="store_true")
parser.add_argument(
"--a11y-method",
choices=("a11y_forwarder_app", "uiautomator"),
default="a11y_forwarder_app",
help="Fixed observation method for H1/candidate runs; H5 paired runs vary it by arm.",
)
parser.add_argument(
"--skip-device-time",
action="store_true",
help="Skip AndroidWorld's per-task date command (needed on this non-root API-35 AVD).",
)
parser.add_argument("--source-paired-run-id")
parser.add_argument("--source-paired-evidence", type=Path)
parser.add_argument("--source-phase1-evidence", type=Path)
parser.add_argument("--source-phase2-evidence", type=Path)
parser.add_argument("--output-dir", type=Path)
parser.add_argument(
"--resume",
action="store_true",
help="Continue missing arms from an existing output-dir evidence checkpoint.",
)
parser.add_argument(
"--retry-errors",
action="store_true",
help="With --resume, discard checkpointed error episodes and execute them again.",
)
args = parser.parse_args()
if args.trials <= 0 or args.max_steps <= 0:
parser.error("--trials and --max-steps must be positive")
if args.input_cost_per_million_usd < 0 or args.output_cost_per_million_usd < 0:
parser.error("model token prices cannot be negative")
if args.trial_indices:
try:
args.trial_indices = sorted(
{int(value.strip()) for value in args.trial_indices.split(",") if value.strip()}
)
except ValueError:
parser.error("--trial-indices must contain only comma-separated integers")
if not args.trial_indices or any(
trial < 1 or trial > args.trials for trial in args.trial_indices
):
parser.error("--trial-indices must be between 1 and --trials")
else:
args.trial_indices = list(range(1, args.trials + 1))
if args.full_suite and args.mode != "candidate-rerun":
parser.error("--full-suite is only valid with --mode candidate-rerun")
if args.mode == "candidate-rerun" and not args.source_paired_evidence:
parser.error("candidate reruns require --source-paired-evidence")
if args.hypothesis in ("H5", "H5C") and args.mode == "paired" and not args.source_phase1_evidence:
parser.error(f"paired {args.hypothesis} runs require --source-phase1-evidence")
if args.hypothesis == "H5C" and args.mode == "paired" and not args.source_phase2_evidence:
parser.error("paired H5C runs require --source-phase2-evidence")
if args.resume and not args.output_dir:
parser.error("--resume requires --output-dir")
if args.retry_errors and not args.resume:
parser.error("--retry-errors requires --resume")
return args
def _select_tasks(args: argparse.Namespace, registry_map: dict[str, Any]) -> list[str]:
if args.full_suite:
names = sorted(registry_map)
if len(names) != BASELINE_TASK_COUNT:
raise RuntimeError(
f"Expected {BASELINE_TASK_COUNT} AndroidWorld tasks at pinned commit; found {len(names)}"
)
return names
names = [name.strip() for name in args.tasks.split(",") if name.strip()]
unknown = sorted(set(names) - set(registry_map))
if unknown:
raise ValueError(f"Unknown AndroidWorld task(s): {', '.join(unknown)}")
return names
def _guidelines_for(task_name: str) -> list[str]:
return list(DEFAULT_GUIDELINES) if task_name in WIFI_TASKS else []
def _trace_step(index: int, result: Any, elapsed_s: float, secrets: list[str]) -> dict[str, Any]:
from android_world.agents import agent_utils, m3a_utils
data = result.data
raw = data.get("action_output") or ""
reason, action_text = m3a_utils.parse_reason_action_output(raw)
action: dict[str, Any] = {}
if action_text:
try:
action = agent_utils.extract_json(action_text)
except Exception: # noqa: BLE001 - malformed model output is evidence.
action = {}
return {
"step": index,
"done": bool(result.done),
"elapsed_s": round(elapsed_s, 6),
"action_type": action.get("action_type", "unparsed"),
"goal_status": action.get("goal_status"),
"reason": redact_text(reason or data.get("summary") or "", secrets)[:1000],
}
def _run_episode(
*,
env: Any,
task_type: Any,
params: dict[str, Any],
task_name: str,
trial: int,
pair_id: str,
pair_seed: int,
arm: str,
order_position: int,
observation_method: str,
task_guidelines: list[str],
llm: OpenAICompatibleLlm,
max_steps: int,
transition_pause: float | None,
secrets: list[str],
) -> dict[str, Any]:
from android_world.agents import t3a
episode = {
"pair_id": pair_id,
"task": task_name,
"trial": trial,
"pair_seed": pair_seed,
"arm": arm,
"order_position": order_position,
"observation_method": observation_method,
"task_guidelines_applied": bool(task_guidelines),
"status": "error",
"params": _jsonable(params),
"goal": None,
"initial_evaluator_reward": None,
"evaluator_reward": None,
"agent_declared_done": False,
"success": False,
"steps": 0,
"elapsed_s": 0.0,
"llm": {},
"trace": [],
"error": None,
}
task = task_type(copy.deepcopy(params))
llm_before = llm.snapshot()
started = time.monotonic()
initialized = False
try:
env.reset(go_home=True)
task.initialize_task(env)
initialized = True
episode["goal"] = str(task.goal)
episode["initial_evaluator_reward"] = float(task.is_successful(env))
agent = t3a.T3A(env, llm, name=f"T3A-{arm}")
agent.transition_pause = transition_pause
if task_guidelines:
agent.set_task_guidelines(task_guidelines)
for step_number in range(1, max_steps + 1):
step_started = time.monotonic()
result = agent.step(task.goal)
episode["trace"].append(
_trace_step(step_number, result, time.monotonic() - step_started, secrets)
)
episode["steps"] = step_number
if result.done:
episode["agent_declared_done"] = True
break
episode["evaluator_reward"] = float(task.is_successful(env))
# Match upstream minimal_task_runner: final state alone is insufficient;
# the agent must also explicitly terminate the episode.
episode["success"] = bool(
episode["agent_declared_done"] and episode["evaluator_reward"] == 1.0
)
episode["status"] = "completed"
except Exception as error: # noqa: BLE001 - preserve per-episode failures.
episode["error"] = redact_text(error, secrets)[:4000]
finally:
episode["elapsed_s"] = round(time.monotonic() - started, 6)
episode["llm"] = OpenAICompatibleLlm.delta(llm_before, llm.snapshot())
if initialized:
try:
task.tear_down(env)
except Exception as error: # noqa: BLE001
teardown_error = redact_text(error, secrets)[:2000]
episode["teardown_error"] = teardown_error
return episode
def _base_evidence(args: argparse.Namespace, run_id: str) -> dict[str, Any]:
guideline_text = "\n".join(DEFAULT_GUIDELINES)
selected = HYPOTHESIS_DEFINITIONS[args.hypothesis]
layered_hypotheses = []
for hypothesis_id, definition in HYPOTHESIS_DEFINITIONS.items():
row = {
key: definition[key]
for key in ("layer", "idea", "target", "verification")
}
row["id"] = hypothesis_id
if hypothesis_id == args.hypothesis:
row["status"] = "tested in this run"
elif args.hypothesis in ("H5", "H5C") and hypothesis_id == "H1":
row["status"] = "tested in source phase 1"
elif args.hypothesis == "H5C" and hypothesis_id == "H5":
row["status"] = "tested in source phase 2"
else:
row["status"] = "not tested"
layered_hypotheses.append(row)
return {
"schema_version": 1,
"experiment": EXPERIMENT_ID,
"run_id": run_id,
"generated_at_utc": _utc_now(),
"command": [Path(sys.argv[0]).name, *sys.argv[1:]],
"credentials_persisted": False,
"baseline": {
"source": "t3a_summary.md and t3a_failed_analysis.md",
"run_date": "2025-07-02",
"agent": "t3a_claude4_sonnet",
"tasks": BASELINE_TASK_COUNT,
"trials_per_task": 1,
"reported_success_rate_approx": 0.88,
"provenance": "historical bundled report; not generated by this runner",
},
"diagnosis": {
"findings": [
"The historical run evaluated 116 tasks once each and reports approximately 88% overall success.",
"Wi-Fi is a concentrated failure cluster: three of the four SystemWifiTurn* rows failed in the bundled per-task table.",
"The capability matrix links the cluster to weak complex_ui_understanding, information_retrieval, and requires_setup behavior.",
"The failed traces show navigation/state-verification loops; increasing the step cap alone would treat a symptom rather than the cause.",
],
"layered_hypotheses": layered_hypotheses,
},
"hypothesis": {
"id": args.hypothesis,
"layer": selected["layer"],
"change": selected["change"],
"expected_result": selected["target"],
"verification": selected["verification"],
"guardrails": HYPOTHESIS_GUARDRAILS[args.hypothesis] + SCOPE_CAVEAT,
"guideline_sha256": (
hashlib.sha256(guideline_text.encode()).hexdigest()
if args.hypothesis == "H1" else None
),
"guidelines": list(DEFAULT_GUIDELINES) if args.hypothesis == "H1" else [],
},
"phase": {
"id": selected["phase"],
"description": selected["phase_description"],
"independent_variable": selected["independent_variable"],
"source_phase1_evidence": (
str(args.source_phase1_evidence.resolve())
if args.source_phase1_evidence else None
),
"source_phase2_evidence": (
str(args.source_phase2_evidence.resolve())
if args.source_phase2_evidence else None
),
},
"model": {
"provider": args.provider,
"model": args.model,
"base_url": args.base_url,
"temperature": 0,
"seed": args.model_seed,
"max_tokens": args.max_model_tokens,
"source": args.model_source,
"revision": args.model_revision,
"runtime": args.model_runtime,
"accelerator": args.accelerator,
"input_cost_per_million_usd": args.input_cost_per_million_usd,
"output_cost_per_million_usd": args.output_cost_per_million_usd,
},
"environment": {},
"environment_boundaries": [],
"scope": {
"mode": args.mode.replace("-", "_"),
"tasks": [],
"trials_per_task": args.trials,
"selected_trials": args.trial_indices,
"max_steps": args.max_steps,
"base_pair_seed": args.seed,
"transition_pause_s": args.transition_pause,
"completed_episodes": 0,
"error_episodes": 0,
},
"episodes": [],
"arm_summary": {},
"paired_comparison": [],
"decision": {},
"llm_analysis": {"status": "pending"},
"experiment_complete": False,
}
def _validate_resume_evidence(evidence: dict[str, Any], args: argparse.Namespace) -> None:
scope = evidence.get("scope", {})
if getattr(args, "full_suite", False):
expected_tasks = list(scope.get("tasks", []))
if len(expected_tasks) != BASELINE_TASK_COUNT:
raise RuntimeError("Full-suite resume checkpoint does not contain 116 tasks")
else:
expected_tasks = [name.strip() for name in args.tasks.split(",") if name.strip()]
recorded_transition_pause = scope.get("transition_pause_s")
if recorded_transition_pause is None:
original_command = evidence.get("command", [])
if "--transition-pause" in original_command:
pause_index = original_command.index("--transition-pause") + 1
try:
recorded_transition_pause = float(original_command[pause_index])
except (IndexError, TypeError, ValueError):
raise RuntimeError(
"Resume checkpoint has an invalid original --transition-pause value"
) from None
if evidence.get("experiment") not in {EXPERIMENT_ID, *LEGACY_EXPERIMENT_IDS}:
raise RuntimeError("Resume checkpoint is not Experiment 7-12 evidence")
checks = {
"hypothesis": (evidence.get("hypothesis", {}).get("id"), args.hypothesis),
"mode": (scope.get("mode"), args.mode.replace("-", "_")),
"tasks": (scope.get("tasks"), expected_tasks),
"trials": (scope.get("trials_per_task"), args.trials),
"selected_trials": (
scope.get("selected_trials", list(range(1, args.trials + 1))),
getattr(args, "trial_indices", list(range(1, args.trials + 1))),
),
"max_steps": (scope.get("max_steps"), args.max_steps),
"model": (evidence.get("model", {}).get("model"), args.model),
"model_seed": (evidence.get("model", {}).get("seed"), args.model_seed),
"provider": (evidence.get("model", {}).get("provider"), args.provider),
"base_url": (evidence.get("model", {}).get("base_url"), args.base_url),
"max_model_tokens": (
evidence.get("model", {}).get("max_tokens"), args.max_model_tokens
),
"model_source": (
evidence.get("model", {}).get("source", "hosted_api"),
getattr(args, "model_source", "hosted_api"),
),
"model_revision": (
evidence.get("model", {}).get("revision"),
getattr(args, "model_revision", None),
),
"model_runtime": (
evidence.get("model", {}).get("runtime"),
getattr(args, "model_runtime", None),
),
"accelerator": (
evidence.get("model", {}).get("accelerator"),
getattr(args, "accelerator", None),
),
"input_cost": (
evidence.get("model", {}).get("input_cost_per_million_usd", 0.0),
getattr(args, "input_cost_per_million_usd", 0.0),
),
"output_cost": (
evidence.get("model", {}).get("output_cost_per_million_usd", 0.0),
getattr(args, "output_cost_per_million_usd", 0.0),
),
"transition_pause": (recorded_transition_pause, args.transition_pause),
"skip_device_time": (
evidence.get("environment", {}).get("skip_device_time"),
args.skip_device_time,
),
"console_port": (
evidence.get("environment", {}).get("device_serial"),
f"emulator-{args.console_port}",
),
"grpc_port": (
evidence.get("environment", {}).get("grpc_port"), args.grpc_port
),
"execution_shard": (
evidence.get("environment", {}).get("execution_shard"),
getattr(args, "execution_shard", None),
),
}
mismatches = [
f"{name}: checkpoint={observed!r}, command={expected!r}"
for name, (observed, expected) in checks.items()
if observed != expected
]
if mismatches:
raise RuntimeError("Resume configuration mismatch: " + "; ".join(mismatches))
seen = set()
for row in evidence.get("episodes", []):
key = (row.get("task"), row.get("trial"), row.get("arm"))
if key in seen:
raise RuntimeError(f"Resume checkpoint contains duplicate episode arm: {key}")
seen.add(key)
try:
task_index = expected_tasks.index(str(row.get("task")))
trial_index = int(row.get("trial")) - 1
except (ValueError, TypeError):
raise RuntimeError("Resume checkpoint has an invalid task/trial episode key") from None
expected_pair_seed = args.seed + task_index * 1009 + trial_index
if row.get("pair_seed") != expected_pair_seed:
raise RuntimeError(
"Resume seed mismatch for "
f"{row.get('task')} trial {row.get('trial')}: "
f"checkpoint={row.get('pair_seed')!r}, command={expected_pair_seed!r}"
)
def _append_boundary(evidence: dict[str, Any], message: str) -> None:
boundaries = evidence.setdefault("environment_boundaries", [])
if message not in boundaries:
boundaries.append(message)
def _choose_paired_decision(
args: argparse.Namespace,
arm_summary: dict[str, dict[str, Any]],
pairs: list[dict[str, Any]],
) -> dict[str, Any]:
if args.hypothesis == "H5C":
return choose_efficiency_decision(arm_summary, pairs)
return choose_decision(arm_summary, pairs)
def main() -> int:
args = _parse_args()
retry_error_params: dict[tuple[str, int], Any] = {}
if args.resume:
output_dir = args.output_dir
evidence = _read_evidence(
output_dir / "evidence.json", purpose="resume checkpoint"
)
_validate_resume_evidence(evidence, args)
run_id = evidence["run_id"]
evidence.setdefault("resume_commands", []).append(
[Path(sys.argv[0]).name, *sys.argv[1:]]
)
if args.retry_errors:
error_episodes = [
copy.deepcopy(row)
for row in evidence.get("episodes", [])
if row.get("status") != "completed"
]
error_count = sum(
row.get("status") != "completed" for row in evidence.get("episodes", [])
)
for row in error_episodes:
key = (str(row.get("task")), int(row.get("trial", 0)))
if key in retry_error_params and retry_error_params[key] != row.get("params"):
raise RuntimeError(
f"Error checkpoint contains inconsistent paired params for {key}"
)
retry_error_params[key] = copy.deepcopy(row.get("params"))
evidence["episodes"] = [
row for row in evidence.get("episodes", []) if row.get("status") == "completed"
]
evidence.setdefault("retry_history", []).append({
"at_utc": _utc_now(),
"discarded_error_episodes": error_count,
"reused_checkpoint_params": len(retry_error_params),
"error_episode_keys": [
{
"task": row.get("task"),
"trial": row.get("trial"),
"arm": row.get("arm"),
}
for row in error_episodes
],
})
# Upgrade interrupted schema-v1 checkpoints with the exact active policy.
evidence["hypothesis"]["guardrails"] = (
HYPOTHESIS_GUARDRAILS[args.hypothesis] + SCOPE_CAVEAT
)
evidence["scope"]["base_pair_seed"] = args.seed
evidence["scope"]["transition_pause_s"] = args.transition_pause
evidence["llm_analysis"] = {"status": "pending"}
evidence.pop("fatal_error", None)
else:
run_id = "exp7-12-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
output_dir = args.output_dir or Path(__file__).resolve().parent / "validation" / run_id
output_dir.mkdir(parents=True, exist_ok=False)
evidence = _base_evidence(args, run_id)
secret_values = [value for key, value in os.environ.items() if "KEY" in key or "TOKEN" in key]
env = None
active_a11y_method = None
emulator_setup_completed = bool(
args.resume
and evidence.get("environment", {}).get("emulator_setup_completed") is True
)
llm = None
fatal_error = None
try:
bootstrap = _bootstrap_android_world(args.android_world_checkout)
from android_world import registry
from android_world.task_evals import task_eval
checkout = args.android_world_checkout.resolve()
commit = _run(["git", "rev-parse", "HEAD"], cwd=checkout)
checkout_status = _run(["git", "status", "--porcelain"], cwd=checkout)
if checkout_status:
raise RuntimeError("AndroidWorld checkout is dirty; refusing an ambiguous run")
if args.source_phase1_evidence:
phase1_source = _read_evidence(
args.source_phase1_evidence.resolve(), purpose="phase-1"
)
_validate_phase1_source(phase1_source)
recorded_phase1 = evidence.get("phase", {}).get("source_phase1_run_id")
if args.resume and recorded_phase1 and recorded_phase1 != phase1_source["run_id"]:
raise RuntimeError("Resume phase-1 source does not match the checkpoint")
evidence["phase"]["source_phase1_run_id"] = phase1_source["run_id"]
evidence["phase"]["source_phase1_decision"] = phase1_source.get("decision")
if args.source_phase2_evidence:
phase2_source = _read_evidence(
args.source_phase2_evidence.resolve(), purpose="phase-2"
)
_validate_phase2_source(phase2_source)
recorded_phase2 = evidence.get("phase", {}).get("source_phase2_run_id")
if args.resume and recorded_phase2 and recorded_phase2 != phase2_source["run_id"]:
raise RuntimeError("Resume phase-2 source does not match the checkpoint")
evidence["phase"]["source_phase2_run_id"] = phase2_source["run_id"]
evidence["phase"]["source_phase2_decision"] = phase2_source.get("decision")
if args.source_paired_evidence:
paired_source = _read_evidence(
args.source_paired_evidence.resolve(), purpose="paired candidate-source"
)
_validate_candidate_source(paired_source, args.hypothesis)
if args.source_paired_run_id and args.source_paired_run_id != paired_source["run_id"]:
raise RuntimeError("--source-paired-run-id does not match --source-paired-evidence")
evidence["decision"] = {
"outcome": "candidate_rerun_in_progress",
"promote_to_full_suite_candidate": True,
"deployment_approved": False,
"source_paired_run_id": paired_source["run_id"],
"source_paired_evidence": str(args.source_paired_evidence.resolve()),
"source_paired_decision": paired_source.get("decision"),
"reason": "The source paired run passed success/cost guardrails; direct candidate rerun evidence is still required.",
}
evidence["decision"]["source_paired_model"] = paired_source.get("model")
source_model = paired_source.get("model", {}).get("model")
if source_model and source_model != args.model:
_append_boundary(
evidence,
"The full-suite candidate uses model "
f"{args.model}, while the promoted paired H5C source used {source_model}. "
"This local-GPU campaign evaluates the promoted observation treatment but is "
"not a same-model extension of the paired result.",
)
api_level = int(_adb(args.adb_path, args.console_port, "shell", "getprop", "ro.build.version.sdk"))
evidence["environment"] = {
"android_world_checkout": str(checkout),
"android_world_commit": commit,
"android_world_checkout_clean": True,
"upstream_tested_device": "Pixel 6",
"upstream_tested_api_level": 33,
"device_serial": f"emulator-{args.console_port}",
"device_model": _adb(args.adb_path, args.console_port, "shell", "getprop", "ro.product.model"),
"avd_name": _adb(args.adb_path, args.console_port, "shell", "getprop", "ro.boot.qemu.avd_name"),
"api_level": api_level,
"physical_size": _adb(args.adb_path, args.console_port, "shell", "wm", "size"),
"grpc_port": args.grpc_port,
"execution_shard": args.execution_shard,
"perform_emulator_setup": args.perform_emulator_setup,
"emulator_setup_completed": emulator_setup_completed,
"skip_device_time": args.skip_device_time,
"a11y_method": (
"varies_by_arm:a11y_forwarder_app_vs_uiautomator"
if args.hypothesis == "H5" and args.mode == "paired"
else "varies_by_arm:uiautomator_vs_uiautomator_compact"
if args.hypothesis == "H5C" and args.mode == "paired"
else _arm_a11y_method(args, "candidate" if args.mode == "candidate-rerun" else "control")
),
"protobuf_bootstrap": bootstrap,
}
app_provisioning = _collect_app_provenance(
args.adb_path, args.console_port
)
evidence["environment"]["app_provisioning"] = app_provisioning
if args.hypothesis in ("H5", "H5C") or args.a11y_method == "uiautomator":
evidence["environment"]["evaluator_compatibility"] = [
_install_uiautomator_evaluator_compatibility(),
_install_clipper_timing_compatibility(),
_install_sms_inbox_timing_compatibility(),
_install_retro_queue_evaluator_compatibility(),
]
evidence["environment"]["model_context_compatibility"] = {
"trigger": "provider context-limit error after compact UIAutomator",
"change": (
"Retain at most 12,000 characters from the ends of the indexed "
"current-screen UI section, or 6,000 characters from the ends of each "
"before/after UI section in the summary prompt. Preserve prompt prefix, goal, history, "
"leading/trailing UI elements with original indices, chosen action, "
"reason, guidance, and output format; retry once."
),
"per_episode_counters": (
"llm.context_prompt_truncations and "
"llm.context_prompt_chars_removed"
),
}
_append_boundary(
evidence,
"ContactsNewContactDraft's official success predicate was fed the upstream "
"UIAutomator state.ui_elements because that observation mode does not populate "
"state.forest; the predicate and requested contact fields were not changed.",
)
_append_boundary(
evidence,
"Clipboard get/set retries once after the exact Clipper foreground-access "
"runtime error; the operation, content, task, and evaluator are unchanged.",
)
_append_boundary(
evidence,
"SimpleSmsReplyMostRecent polls the unchanged inbox query for up to five "
"additional seconds because emulator-injected SMS delivery can lag past "
"upstream's fixed wait. If the inbox remains empty, the exact last injected "
"address/body is inserted into the same SMS database that upstream clears "
"directly; task data and the evaluator are unchanged.",
)
_append_boundary(
evidence,
"The pinned official Retro Music APK omits the playing_queue table, a known "
"upstream runtime error. Only that exact missing-table condition was mapped "
"to an empty observed queue so the unchanged exact queue predicate records "
"an evaluator failure instead of losing the episode.",
)
_append_boundary(
evidence,
"If compact UIAutomator still exceeds the pinned model's native 32,768-token "
"context, the retry removes a bounded middle span only from indexed UI "
"descriptions: at most 12,000 retained characters for action selection or "
"6,000 for each before/after summary screen. Prompt prefix, goal, history, action, reason, "
"leading/trailing UI elements and indices, guidance, and output format remain; "
"per-episode removal counters are retained.",
)
if api_level != 33:
_append_boundary(evidence,
f"The available AVD is API {api_level}, while upstream is tested on Pixel 6 / API 33. Results are real but not reference-environment comparable."
)
if not app_provisioning["complete"]:
_append_boundary(evidence,
"The official AndroidWorld app bundle is incomplete; missing packages: "
+ ", ".join(app_provisioning["missing_packages"])
)
if args.hypothesis in ("H5", "H5C") or args.a11y_method == "uiautomator":
_append_boundary(evidence,
"UIAutomator is an upstream AndroidWorld observation option selected by the companion runner; it preserves real UI actions/evaluators but is a compatibility path, not the upstream API-33 reference configuration."
)
if args.hypothesis == "H5C":
_append_boundary(evidence,
"Compact UIAutomator removes only non-semantic container nodes; observations, coordinates, Android actions, and AndroidWorld evaluators remain real."
)
if retry_error_params:
_append_boundary(
evidence,
"Runtime-error retries reuse the exact task parameters retained in the "
"discarded error checkpoints; upstream parameter-generator drift cannot "
"silently change the retried task.",
)
if args.skip_device_time:
task_eval.TaskEval.initialize_device_time = lambda self, environment: None
_append_boundary(evidence,
"Per-task device-time setting was skipped because the non-root API-35 AVD rejects `adb shell date`; Wi-Fi evaluators do not depend on time."
)
task_registry = registry.TaskRegistry()
registry_map = task_registry.get_registry(task_registry.ANDROID_WORLD_FAMILY)
task_names = _select_tasks(args, registry_map)
evidence["scope"]["tasks"] = task_names
api_key = os.environ.get(args.api_key_env)
if not api_key:
raise RuntimeError(f"Required real API credential is not set: {args.api_key_env}")
llm = OpenAICompatibleLlm(
api_key=api_key,
base_url=args.base_url,
model=args.model,
seed=args.model_seed,
max_tokens=args.max_model_tokens,
timeout_s=args.model_timeout_s,
retries=args.model_retries,
input_cost_per_million_usd=args.input_cost_per_million_usd,
output_cost_per_million_usd=args.output_cost_per_million_usd,
)
checkpoint_arm_keys = {
(row["task"], int(row["trial"]), row["arm"])
for row in evidence.get("episodes", [])
}
for task_index, task_name in enumerate(task_names):
task_type = registry_map[task_name]
for trial in (index - 1 for index in args.trial_indices):
pair_seed = args.seed + task_index * 1009 + trial
random.seed(pair_seed)
params = task_type.generate_random_params()
pair_id = f"{task_name}:trial-{trial + 1}:seed-{pair_seed}"
retry_key = (task_name, trial + 1)
if retry_key in retry_error_params:
generated_params = _jsonable(params)
params = copy.deepcopy(retry_error_params[retry_key])
if generated_params != _jsonable(params):
evidence.setdefault("retry_parameter_drift", []).append({
"pair_id": pair_id,
"reason": (
"The upstream generator drifted on resume; the exact parameters "
"from the runtime-error checkpoint were reused."
),
})
for checkpoint_episode in evidence.get("episodes", []):
if checkpoint_episode.get("pair_id") == pair_id:
if checkpoint_episode.get("params") != _jsonable(params):
if args.mode == "candidate-rerun" and checkpoint_episode.get("arm") == "candidate":
evidence.setdefault("resume_parameter_drift", []).append({
"pair_id": pair_id,
"reason": (
"The upstream parameter generator is not fully controlled by "
"Python random.seed; the completed checkpoint remains canonical "
"and was skipped rather than rerun."
),
})
else:
raise RuntimeError(
f"Generated params do not match checkpoint for {pair_id}"
)
if args.mode == "paired":
arms = ["control", "treatment"]
if (task_index + trial) % 2:
arms.reverse()
else:
arms = ["candidate"]
for order_position, arm in enumerate(arms, start=1):
arm_key = (task_name, trial + 1, arm)
if arm_key in checkpoint_arm_keys:
print(f"\n=== {pair_id} / {arm} already checkpointed; skipping ===", flush=True)
continue
arm_a11y_method = _arm_a11y_method(args, arm)
if env is None or active_a11y_method != arm_a11y_method:
if env is not None:
env.close()
env = None
active_a11y_method = None
load_args = copy.copy(args)
load_args.perform_emulator_setup = bool(
args.perform_emulator_setup and not emulator_setup_completed
)
env = _load_android_env(load_args, arm_a11y_method)
if load_args.perform_emulator_setup:
emulator_setup_completed = True
evidence["environment"]["emulator_setup_completed"] = True
evidence["environment"]["app_provisioning"] = _collect_app_provenance(
args.adb_path, args.console_port
)
active_a11y_method = arm_a11y_method
print(f"\n=== {pair_id} / {arm} ({order_position}/{len(arms)}) ===", flush=True)
episode = _run_episode(
env=env,
task_type=task_type,
params=params,
task_name=task_name,
trial=trial + 1,
pair_id=pair_id,
pair_seed=pair_seed,
arm=arm,
order_position=order_position,
observation_method=arm_a11y_method,
task_guidelines=_arm_guidelines(args, arm, task_name),
llm=llm,
max_steps=args.max_steps,
transition_pause=args.transition_pause,
secrets=secret_values,
)
evidence["episodes"].append(episode)
# Checkpoint after every real episode so interruption does not erase evidence.
evidence["scope"]["completed_episodes"] = sum(
row["status"] == "completed" for row in evidence["episodes"]
)
evidence["scope"]["error_episodes"] = sum(
row["status"] != "completed" for row in evidence["episodes"]
)
evidence["arm_summary"] = aggregate_episodes(evidence["episodes"])
evidence["paired_comparison"] = paired_rows(evidence["episodes"])
if args.mode == "paired":
evidence["decision"] = _choose_paired_decision(
args, evidence["arm_summary"], evidence["paired_comparison"]
)
enforce_scope_claims(evidence)
(output_dir / "evidence.json").write_text(dumps_json(evidence), encoding="utf-8")
if episode["status"] != "completed" and env is not None:
env.close()
env = None
active_a11y_method = None
except Exception as error: # noqa: BLE001 - always persist an external blocker.
fatal_error = redact_text(error, secret_values)[:4000]
evidence["fatal_error"] = fatal_error
_append_boundary(evidence, f"Run stopped by environment/runtime blocker: {fatal_error}")
finally:
if env is not None:
try:
env.close()
except Exception as error: # noqa: BLE001
_append_boundary(evidence,
"Environment close error: " + redact_text(error, secret_values)[:1000]
)
evidence["generated_at_utc"] = _utc_now()
evidence["scope"]["completed_episodes"] = sum(
row["status"] == "completed" for row in evidence["episodes"]
)
evidence["scope"]["error_episodes"] = sum(
row["status"] != "completed" for row in evidence["episodes"]
)
evidence["arm_summary"] = aggregate_episodes(evidence["episodes"])
evidence["paired_comparison"] = paired_rows(evidence["episodes"])
if args.mode == "paired":
evidence["decision"] = _choose_paired_decision(
args, evidence["arm_summary"], evidence["paired_comparison"]
)
enforce_scope_claims(evidence)
if args.mode == "candidate-rerun":
if evidence["scope"]["full_suite_completed"]:
evidence["decision"].update({
"outcome": "full_candidate_rerun_completed",
"deployment_approved": False,
"reason": "The direct 116-task × five-trial candidate rerun completed. Deployment still requires reviewing the observed success/cost result against product thresholds.",
})
elif evidence["scope"]["error_episodes"]:
evidence["decision"].update({
"outcome": "candidate_rerun_has_errors",
"deployment_approved": False,
"reason": "The candidate rerun contains episode errors and cannot satisfy the completion or deployment gate.",
})
else:
evidence["decision"].update({
"outcome": "candidate_subset_rerun_completed",
"deployment_approved": False,
"reason": "A real modified candidate subset rerun completed, but it is not the 116-task × five-trial gate and cannot approve deployment.",
})
if llm is not None and evidence["episodes"]:
evidence["llm_analysis"] = _generate_llm_analysis(
evidence, llm, secret_values
)
elif fatal_error:
evidence["llm_analysis"] = {
"status": "not_run",
"error": "No completed episode evidence was available for LLM analysis.",
}
(output_dir / "evidence.json").write_text(dumps_json(evidence), encoding="utf-8")
(output_dir / "report.md").write_text(render_report(evidence), encoding="utf-8")
print(f"Evidence: {output_dir / 'evidence.json'}")
print(f"Report: {output_dir / 'report.md'}")
return 2 if fatal_error else 0
if __name__ == "__main__":
raise SystemExit(main())