From 81d697f210d0334853b7eed91d0d46c09aea28c0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:37:34 +0000 Subject: [PATCH] test: feature - unit tests for study, reproducibility_util, args, view_dep_graph, multi_server (5 bug xfails) Co-Authored-By: Daniella Grimberg --- tests/experiments/test_args_unit.py | 245 ++++++ tests/experiments/test_multi_server_unit.py | 157 ++++ .../test_reproducibility_util_unit.py | 468 +++++++++++ tests/experiments/test_study_unit.py | 730 ++++++++++++++++++ tests/experiments/test_view_dep_graph.py | 176 +++++ 5 files changed, 1776 insertions(+) create mode 100644 tests/experiments/test_args_unit.py create mode 100644 tests/experiments/test_multi_server_unit.py create mode 100644 tests/experiments/test_reproducibility_util_unit.py create mode 100644 tests/experiments/test_study_unit.py create mode 100644 tests/experiments/test_view_dep_graph.py diff --git a/tests/experiments/test_args_unit.py b/tests/experiments/test_args_unit.py new file mode 100644 index 000000000..9fb76b6bd --- /dev/null +++ b/tests/experiments/test_args_unit.py @@ -0,0 +1,245 @@ +from dataclasses import dataclass, field + +import numpy as np +import pytest + +from agentlab.experiments import args as args_mod +from agentlab.experiments.args import ( + TOGGLE, + Choice, + CrossProd, + Distribution, + Toggle, + _apply_change, + _change_value, + _find_cprod_with_paths, + _sample_single, + _set_value, + expand_cross_product, + make_ablation_study, + make_progression_study, + sample_and_expand_cross_product, + sample_args, +) + + +@dataclass +class Inner: + name: str = "a" + flag: bool = False + value: int = 0 + + +@dataclass +class Outer: + inner: Inner = field(default_factory=Inner) + count: int = 1 + extra: dict = field(default_factory=dict) + + +class PlainObj: + def __init__(self): + self.inner = Inner() + self.flag = True + self.label = "x" + + +def test_expand_top_level_cross_prod_returns_elements(): + assert expand_cross_product(CrossProd([1, 2, 3])) == [1, 2, 3] + + +def test_expand_without_cross_prod_returns_deep_copy(): + obj = Outer(extra={"k": [1]}) + result = expand_cross_product(obj) + assert result == [obj] + assert result[0] is not obj + assert result[0].extra is not obj.extra + + +def test_expand_list_input_mixes_plain_and_cross_prod_objects(): + plain = Outer(count=7) + crossed = Outer(count=CrossProd([1, 2]), inner=Inner(name=CrossProd(["x", "y"]))) + result = expand_cross_product([plain, crossed]) + assert len(result) == 1 + 4 + assert result[0].count == 7 + combos = sorted((o.count, o.inner.name) for o in result[1:]) + assert combos == [(1, "x"), (1, "y"), (2, "x"), (2, "y")] + + +def test_expand_does_not_mutate_original(): + obj = Outer(count=CrossProd([1, 2])) + expand_cross_product(obj) + assert isinstance(obj.count, CrossProd) + + +def test_expand_cross_prod_inside_dict_field_of_dataclass(): + obj = Outer(extra={"lr": CrossProd([0.1, 0.2])}) + result = expand_cross_product(obj) + assert sorted(o.extra["lr"] for o in result) == [0.1, 0.2] + + +def test_find_cprod_with_paths_reports_nested_paths(): + cp1, cp2 = CrossProd([1]), CrossProd([2]) + obj = Outer(inner=Inner(name=cp1), extra={"a": {"b": cp2}}) + found = _find_cprod_with_paths(obj) + assert (["inner", "name"], cp1) in found + assert (["extra", "a", "b"], cp2) in found + assert len(found) == 2 + + +@pytest.mark.parametrize("obj", [1, "str", [CrossProd([1])], None]) +def test_find_cprod_ignores_non_container_types(obj): + # lists are not traversed, only dataclasses and dicts + assert _find_cprod_with_paths(obj) == [] + + +def test_set_value_traverses_dicts_and_objects(): + obj = Outer(extra={"nested": Inner()}) + _set_value(obj, ["extra", "nested", "value"], 42) + _set_value(obj, ["inner", "name"], "z") + _set_value(obj, ["extra", "new"], 1) + assert obj.extra["nested"].value == 42 + assert obj.inner.name == "z" + assert obj.extra["new"] == 1 + + +def test_distribution_base_sample_returns_none(): + assert Distribution().sample() is None + + +def test_choice_respects_probabilities(): + choice = Choice(["a", "b", "c"], p=[0.0, 1.0, 0.0]) + assert {choice.sample() for _ in range(10)} == {"b"} + + +def test_choice_uses_given_rng(): + choice = Choice([1, 2, 3, 4, 5]) + s1 = [choice.sample(np.random.RandomState(0)) for _ in range(3)] + s2 = [choice.sample(np.random.RandomState(0)) for _ in range(3)] + assert s1 == s2 + + +def test_sample_args_list_input_and_deep_copies(): + a = Outer(count=Choice([5], p=[1.0])) + b = {"x": Choice(["only"])} + result = sample_args([a, b], 2) + assert len(result) == 4 + assert [r.count for r in result[:2]] == [5, 5] + assert [r["x"] for r in result[2:]] == ["only", "only"] + assert isinstance(a.count, Choice) + assert isinstance(b["x"], Choice) + assert result[0] is not result[1] + + +def test_sample_single_top_level_distribution(): + assert _sample_single(Choice([9])) == 9 + + +def test_sample_single_leaves_plain_values_untouched(): + assert _sample_single(3) == 3 + + +def test_sample_and_expand_cross_product(): + obj = Outer(count=Choice([3]), inner=Inner(name=CrossProd(["p", "q"]))) + result = sample_and_expand_cross_product(obj, n_samples=2) + assert len(result) == 4 + assert all(r.count == 3 for r in result) + assert sorted(r.inner.name for r in result) == ["p", "p", "q", "q"] + + +@pytest.mark.parametrize("path", ["inner.name", ".inner.name"]) +def test_change_value_nested_path_with_optional_leading_dot(path): + obj = Outer() + _change_value(obj, path, "new") + assert obj.inner.name == "new" + + +def test_change_value_unknown_dataclass_field_raises(): + with pytest.raises(ValueError, match="field missing not found"): + _change_value(Outer(), "inner.missing", 1) + + +def test_change_value_on_plain_object_sets_any_attribute(): + obj = PlainObj() + _change_value(obj, "brand_new", 5) + assert obj.brand_new == 5 + + +def test_change_value_toggle_flips_boolean_on_dataclass_and_object(): + obj = Outer() + _change_value(obj, "inner.flag", TOGGLE) + assert obj.inner.flag is True + _change_value(obj, "inner.flag", TOGGLE) + assert obj.inner.flag is False + + plain = PlainObj() + _change_value(plain, "flag", Toggle()) + assert plain.flag is False + + +def test_change_value_toggle_non_boolean_raises(): + with pytest.raises(ValueError, match="not a boolean"): + _change_value(Outer(), "inner.name", TOGGLE) + + +def test_apply_change_callable(): + obj = Outer() + + def change(params): + params.count = 99 + + assert _apply_change(obj, change) is obj + assert obj.count == 99 + + +def test_apply_change_list_of_changes_and_nested(): + obj = Outer() + _apply_change(obj, [("count", 3), [("inner.name", "n"), ("inner.value", 4)]]) + assert (obj.count, obj.inner.name, obj.inner.value) == (3, "n", 4) + + +@pytest.mark.parametrize("bad", [42, "count", None]) +def test_apply_change_unrecognized_raises(bad): + with pytest.raises(ValueError, match="not recognized"): + _apply_change(Outer(), bad) + + +def test_make_progression_study_accumulates_and_returns_list(): + start = Inner() + result = make_progression_study( + start, [("name", "b"), ("value", 2), ("flag", TOGGLE)], return_cross_prod=False + ) + assert isinstance(result, list) + assert [(r.name, r.value, r.flag) for r in result] == [ + ("a", 0, False), + ("b", 0, False), + ("b", 2, False), + ("b", 2, True), + ] + assert result[0] is start + assert start == Inner() + + +def test_make_ablation_study_resets_each_time_and_returns_list(): + start = Inner() + result = make_ablation_study( + start, [("name", "b"), ("value", 2), ("flag", TOGGLE)], return_cross_prod=False + ) + assert [(r.name, r.value, r.flag) for r in result] == [ + ("a", 0, False), + ("b", 0, False), + ("a", 2, False), + ("a", 0, True), + ] + assert start == Inner() + + +@pytest.mark.parametrize("fn", [make_progression_study, make_ablation_study]) +def test_study_builders_return_cross_prod_by_default(fn): + result = fn(Inner(), [("value", 1)]) + assert isinstance(result, CrossProd) + assert len(result.elements) == 2 + + +def test_toggle_singleton_is_toggle(): + assert isinstance(args_mod.TOGGLE, Toggle) diff --git a/tests/experiments/test_multi_server_unit.py b/tests/experiments/test_multi_server_unit.py new file mode 100644 index 000000000..b990d7289 --- /dev/null +++ b/tests/experiments/test_multi_server_unit.py @@ -0,0 +1,157 @@ +import os +import sys +import types + +import pytest + +from agentlab.experiments import multi_server +from agentlab.experiments.multi_server import ( + BaseServer, + WebArenaInstanceVars, + _split_url, + unimport_modules, +) + +KEYS = [ + "SHOPPING", + "SHOPPING_ADMIN", + "REDDIT", + "GITLAB", + "WIKIPEDIA", + "MAP", + "HOMEPAGE", + "FULL_RESET", +] + + +def _make_instance(**overrides): + kwargs = dict( + base_url="http://wa.example.com", + shopping="8082/", + shopping_admin="8083/admin", + reddit="8080", + gitlab="9001", + wikipedia="8081/wiki", + map="443", + homepage="80", + full_reset="7565", + module_name="fake_wa_module_for_tests", + prefix="TESTWA_", + ) + kwargs.update(overrides) + return WebArenaInstanceVars(**kwargs) + + +@pytest.fixture +def clean_prefix_env(monkeypatch): + for prefix in ("TESTWA_", "WA_"): + for key in list(os.environ): + if key.startswith(prefix): + monkeypatch.delenv(key) + # pre-register every key so monkeypatch restores os.environ after init() writes to it + for key in KEYS: + monkeypatch.setenv(f"{prefix}{key}", "placeholder") + monkeypatch.delenv(f"{prefix}{key}") + return monkeypatch + + +def test_base_server_init_is_noop(): + assert BaseServer().init() is None + + +@pytest.mark.parametrize( + "url, expected", + [ + ("http://host:8080", ("http://host", "8080")), + ("http://host:8083/admin", ("http://host", "8083/admin")), + ("http://host", ("http://host", "")), + ("https://h.com:1/a:b", ("https://h.com", "1/a:b")), + ], +) +def test_split_url(url, expected): + assert _split_url(url) == expected + + +def test_make_env_vars_uses_prefix_and_base_url(): + env = _make_instance().make_env_vars() + assert set(env) == {f"TESTWA_{k}" for k in KEYS} + assert env["TESTWA_SHOPPING_ADMIN"] == "http://wa.example.com:8083/admin" + assert env["TESTWA_FULL_RESET"] == "http://wa.example.com:7565" + assert env["TESTWA_WIKIPEDIA"] == "http://wa.example.com:8081/wiki" + + +def test_clone_is_independent_deep_copy(): + original = _make_instance() + clone = original.clone() + assert clone == original + assert clone is not original + clone.base_url = "http://other" + assert original.base_url == "http://wa.example.com" + + +def test_unimport_modules_removes_only_matching_prefix(monkeypatch): + monkeypatch.setitem(sys.modules, "zz_unimport_pkg", types.ModuleType("zz_unimport_pkg")) + monkeypatch.setitem(sys.modules, "zz_unimport_pkg.sub", types.ModuleType("sub")) + monkeypatch.setitem(sys.modules, "zz_other_pkg", types.ModuleType("zz_other_pkg")) + unimport_modules("zz_unimport_pkg") + assert "zz_unimport_pkg" not in sys.modules + assert "zz_unimport_pkg.sub" not in sys.modules + assert "zz_other_pkg" in sys.modules + + +def test_from_env_vars_roundtrip_default_prefix(clean_prefix_env): + instance = _make_instance(prefix="WA_", module_name="webarena") + for key, value in instance.make_env_vars().items(): + clean_prefix_env.setenv(key, value) + assert WebArenaInstanceVars.from_env_vars() == instance + + +@pytest.mark.xfail( + strict=True, reason="BUG: from_env_vars drops a custom prefix; instance keeps prefix='WA_'" +) +def test_from_env_vars_roundtrip_custom_prefix(clean_prefix_env): + instance = _make_instance() + for key, value in instance.make_env_vars().items(): + clean_prefix_env.setenv(key, value) + loaded = WebArenaInstanceVars.from_env_vars( + prefix="TESTWA_", module_name="fake_wa_module_for_tests" + ) + assert loaded == instance + + +def test_from_env_vars_multiple_base_urls_raises(clean_prefix_env): + clean_prefix_env.setenv("TESTWA_SHOPPING", "http://a.com:8082") + clean_prefix_env.setenv("TESTWA_REDDIT", "http://b.com:8080") + with pytest.raises(ValueError, match="Multiple base urls"): + WebArenaInstanceVars.from_env_vars(prefix="TESTWA_") + + +class _FakeWebArenaInstance: + """Mimics browsergym's WebArenaInstance by reading urls from the env at construction.""" + + prefix = "TESTWA_" + + def __init__(self): + self.urls = {"reddit": os.environ[f"{self.prefix}REDDIT"]} + + +def test_init_sets_env_vars_and_unimports_module(clean_prefix_env): + clean_prefix_env.setattr(multi_server, "WebArenaInstance", _FakeWebArenaInstance) + clean_prefix_env.setitem( + sys.modules, "fake_wa_module_for_tests", types.ModuleType("fake_wa_module_for_tests") + ) + instance = _make_instance() + instance.init() + assert "fake_wa_module_for_tests" not in sys.modules + for key, value in instance.make_env_vars().items(): + assert os.environ[key] == value + + +def test_init_asserts_base_url_matches(clean_prefix_env): + class WrongInstance: + def __init__(self): + self.urls = {"reddit": "http://someone-else.com:8080"} + + clean_prefix_env.setattr(multi_server, "WebArenaInstance", WrongInstance) + with pytest.raises(AssertionError, match="Expected http://wa.example.com"): + _make_instance().init() diff --git a/tests/experiments/test_reproducibility_util_unit.py b/tests/experiments/test_reproducibility_util_unit.py new file mode 100644 index 000000000..b77fc9e31 --- /dev/null +++ b/tests/experiments/test_reproducibility_util_unit.py @@ -0,0 +1,468 @@ +import csv +import logging +from importlib import metadata +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pandas as pd +import pytest +from git import InvalidGitRepositoryError, Repo + +from agentlab.experiments import reproducibility_util as repro + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _report(rows): + df = pd.DataFrame( + rows, columns=["agent.agent_name", "avg_reward", "std_err", "n_err", "n_completed"] + ) + return df.set_index("agent.agent_name") + + +def _init_repo(path: Path, monkeypatch) -> Repo: + for var in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"): + monkeypatch.setenv(var, "Unit Tester") + for var in ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"): + monkeypatch.setenv(var, "unit@test.invalid") + repo = Repo.init(path) + with repo.config_writer() as cw: + cw.set_value("user", "name", "Repo Local User") + cw.set_value("user", "email", "unit@test.invalid") + pkg = path / "pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("x = 1\n") + (path / "README.md").write_text("readme\n") + repo.index.add(["pkg/__init__.py", "README.md"]) + repo.index.commit("initial") + return repo + + +def _fake_module(path: Path): + return SimpleNamespace(__file__=str(path / "pkg" / "__init__.py"), __version__="9.9.9") + + +# --------------------------------------------------------------------------- +# _get_benchmark_version +# --------------------------------------------------------------------------- + + +def test_benchmark_version_prefers_get_version(): + bench = SimpleNamespace(name="anything", get_version=lambda: "1.2.3") + assert repro._get_benchmark_version(bench) == "1.2.3" + + +@pytest.mark.parametrize( + "name, dist", + [ + ("miniwob_tiny_test", "browsergym.miniwob"), + ("workarena_l1", "browsergym.workarena"), + ("webarena_tiny", "browsergym.webarena"), + ("visualwebarena", "browsergym.visualwebarena"), + ("weblinx_test", "weblinx_browsergym"), + ("assistantbench", "browsergym.assistantbench"), + ], +) +def test_benchmark_version_from_distribution(monkeypatch, name, dist): + calls = [] + + def fake_distribution(d): + calls.append(d) + return SimpleNamespace(version=f"v-{d}") + + monkeypatch.setattr(repro.metadata, "distribution", fake_distribution) + assert repro._get_benchmark_version(SimpleNamespace(name=name)) == f"v-{dist}" + assert calls == [dist] + + +def test_benchmark_version_weblinx_missing_package_falls_back(monkeypatch): + def raise_not_found(d): + raise metadata.PackageNotFoundError(d) + + monkeypatch.setattr(repro.metadata, "distribution", raise_not_found) + assert repro._get_benchmark_version(SimpleNamespace(name="weblinx")) == "0.0.1rc1" + + +def test_benchmark_version_unknown_raises_or_bypasses(): + bench = SimpleNamespace(name="my_custom_bench") + with pytest.raises(ValueError, match="Unknown benchmark my_custom_bench"): + repro._get_benchmark_version(bench) + assert repro._get_benchmark_version(bench, allow_bypass_benchmark_version=True) == "bypassed" + + +# --------------------------------------------------------------------------- +# _get_git_username +# --------------------------------------------------------------------------- + + +def test_git_username_from_repo_config(tmp_path, monkeypatch): + repo = _init_repo(tmp_path, monkeypatch) + assert repro._get_git_username(repo) == "Repo Local User" + + +def _bare_repo_mock(remote_url="https://gitlab.com/x/y.git"): + repo = mock.MagicMock() + repo.config_reader.return_value.get_value.return_value = None + repo.remotes.origin.url = remote_url + return repo + + +def test_git_username_from_github_api(monkeypatch): + repo = _bare_repo_mock("git@github.com:some-owner/some-repo.git") + response = mock.MagicMock() + response.read.return_value = b'{"owner": {"login": "gh-owner"}}' + response.__enter__.return_value = response + with mock.patch("urllib.request.urlopen", return_value=response) as urlopen: + assert repro._get_git_username(repo) == "gh-owner" + urlopen.assert_called_once_with("https://api.github.com/repos/some-owner/some-repo") + + +@pytest.mark.parametrize("scope, expected", [("--global", "global-user"), ("--system", "sys-user")]) +def test_git_username_from_global_then_system_config(monkeypatch, scope, expected): + repo = _bare_repo_mock() + repo.git.config.side_effect = lambda s, _list: s + + class FakeParser: + def __init__(self, source, read_only): + self.source = source + + def get_value(self, section, option, default): + if self.source == "--global": + return "global-user" if scope == "--global" else None + return "sys-user" + + monkeypatch.setattr(repro, "GitConfigParser", FakeParser) + assert repro._get_git_username(repo) == expected + + +@pytest.mark.parametrize( + "author, committer, expected", + [("auth", "comm", "auth"), (None, "comm", "comm"), (None, None, None)], +) +def test_git_username_env_fallback(monkeypatch, author, committer, expected): + for var, value in (("GIT_AUTHOR_NAME", author), ("GIT_COMMITTER_NAME", committer)): + if value is None: + monkeypatch.delenv(var, raising=False) + else: + monkeypatch.setenv(var, value) + # repo=None (no git repo) makes every lookup fail -> env vars + assert repro._get_git_username(None) == expected + + +# --------------------------------------------------------------------------- +# _get_git_info +# --------------------------------------------------------------------------- + + +def test_git_info_clean_repo(tmp_path, monkeypatch): + repo = _init_repo(tmp_path, monkeypatch) + git_hash, modified = repro._get_git_info(_fake_module(tmp_path)) + assert git_hash == repo.head.object.hexsha + assert modified == [] + + +def test_git_info_reports_unstaged_and_untracked(tmp_path, monkeypatch): + _init_repo(tmp_path, monkeypatch) + (tmp_path / "pkg" / "__init__.py").write_text("x = 2\n") + (tmp_path / "new_untracked.txt").write_text("u") + _, modified = repro._get_git_info(_fake_module(tmp_path)) + assert ("M", Path("pkg/__init__.py")) in modified + assert ("??", Path("new_untracked.txt")) in modified + assert len(modified) == 2 + + +def test_git_info_staged_modification(tmp_path, monkeypatch): + repo = _init_repo(tmp_path, monkeypatch) + (tmp_path / "README.md").write_text("changed\n") + repo.index.add(["README.md"]) + _, modified = repro._get_git_info(_fake_module(tmp_path)) + assert modified == [("M", Path("README.md"))] + + +@pytest.mark.xfail( + strict=True, + reason="BUG: index.diff(head.commit) is reversed, so a staged new file is reported as 'D'", +) +def test_git_info_staged_new_file_reported_as_added(tmp_path, monkeypatch): + repo = _init_repo(tmp_path, monkeypatch) + (tmp_path / "added.txt").write_text("a") + repo.index.add(["added.txt"]) + _, modified = repro._get_git_info(_fake_module(tmp_path)) + assert modified == [("A", Path("added.txt"))] + + +def test_git_info_white_list_filters_matches(tmp_path, monkeypatch): + _init_repo(tmp_path, monkeypatch) + (tmp_path / "main.py").write_text("") + (tmp_path / "reproducibility_journal.csv").write_text("") + (tmp_path / "keep.py").write_text("") + _, modified = repro._get_git_info( + _fake_module(tmp_path), changes_white_list=("*main.py", "*reproducibility_journal.csv") + ) + assert modified == [("??", Path("keep.py"))] + + +def test_git_info_not_a_repo(tmp_path): + (tmp_path / "pkg").mkdir() + with mock.patch.object(repro, "_get_repo", side_effect=InvalidGitRepositoryError("nope")): + assert repro._get_git_info(_fake_module(tmp_path)) == (None, []) + + +def test_get_repo_finds_parent_repository(tmp_path, monkeypatch): + repo = _init_repo(tmp_path, monkeypatch) + found = repro._get_repo(_fake_module(tmp_path)) + assert Path(found.working_tree_dir).resolve() == Path(repo.working_tree_dir).resolve() + + +# --------------------------------------------------------------------------- +# get_reproducibility_info +# --------------------------------------------------------------------------- + + +@pytest.fixture +def patched_git(monkeypatch): + git_infos = {} + + def fake_git_info(module, changes_white_list=()): + return git_infos.get(module.__name__, ("deadbeef", [])) + + monkeypatch.setattr(repro, "_get_repo", lambda module: "fake-repo") + monkeypatch.setattr(repro, "_get_git_username", lambda repo: f"user-of-{repo}") + monkeypatch.setattr(repro, "_get_git_info", fake_git_info) + return git_infos + + +def test_reproducibility_info_contents(patched_git): + bench = SimpleNamespace(name="bench_x", get_version=lambda: "4.5") + info = repro.get_reproducibility_info("agent_a", bench, study_id="sid", comment="hello") + assert info["agent_names"] == ["agent_a"] + assert info["git_user"] == "user-of-fake-repo" + assert info["benchmark"] == "bench_x" + assert info["benchmark_version"] == "4.5" + assert info["study_id"] == "sid" + assert info["comment"] == "hello" + assert info["playwright_version"] == metadata.distribution("playwright").version + for module in ("agentlab", "browsergym"): + assert info[f"{module}_git_hash"] == "deadbeef" + assert info[f"{module}__local_modifications"] == "" + assert f"{module}_version" in info + + +def test_reproducibility_info_without_git_repo(patched_git, monkeypatch): + def raise_invalid(module): + raise InvalidGitRepositoryError("no repo") + + monkeypatch.setattr(repro, "_get_repo", raise_invalid) + bench = SimpleNamespace(name="unknown_bench") + info = repro.get_reproducibility_info(["a", "b"], bench, allow_bypass_benchmark_version=True) + assert info["git_user"] == "user-of-None" + assert info["agent_names"] == ["a", "b"] + assert info["benchmark_version"] == "bypassed" + + +def test_reproducibility_info_local_changes_raise(patched_git): + patched_git["agentlab"] = ("abc", [("M", Path("src/x.py"))]) + bench = SimpleNamespace(name="b", get_version=lambda: "1") + with pytest.raises( + ValueError, match=r"(?s)Module agentlab has uncommitted changes.*M: src/x.py" + ): + repro.get_reproducibility_info("a", bench) + + +def test_reproducibility_info_local_changes_ignored(patched_git, caplog): + patched_git["browsergym.core"] = ("abc", [("??", Path("new.py"))]) + bench = SimpleNamespace(name="b", get_version=lambda: "1") + with caplog.at_level(logging.WARNING): + info = repro.get_reproducibility_info("a", bench, ignore_changes=True) + assert info["browsergym__local_modifications"] == " ??: new.py" + assert info["browsergym_git_hash"] == "abc" + assert "Ignoring changes as requested" in caplog.text + + +# --------------------------------------------------------------------------- +# assert_compatible / _raise_or_warn +# --------------------------------------------------------------------------- + + +def test_assert_compatible_ignores_volatile_keys(): + info = {"a": 1, "date": "today", "avg_reward": 0.5, "n_err": 0} + old = {"a": 1, "date": "yesterday", "avg_reward": 0.1, "n_err": 3} + repro.assert_compatible(info, old) + + +def test_assert_compatible_raises_on_changed_key(): + with pytest.raises(ValueError, match="Key a has changed from 1 to 2"): + repro.assert_compatible({"a": 2}, {"a": 1}) + + +def test_assert_compatible_warns_when_not_strict(caplog): + with caplog.at_level(logging.WARNING): + repro.assert_compatible({"a": 2}, {"a": 1}, raise_if_incompatible=False) + assert "Key a has changed" in caplog.text + + +def test_raise_or_warn(caplog): + with pytest.raises(ValueError, match="boom"): + repro._raise_or_warn("boom") + with caplog.at_level(logging.WARNING): + repro._raise_or_warn("soft", raise_error=False) + assert "soft" in caplog.text + + +# --------------------------------------------------------------------------- +# _verify_report +# --------------------------------------------------------------------------- + + +def test_verify_report_ok_returns_indexed_df(): + df = repro._verify_report(_report([["a", 0.5, 0.1, 0, "4/4"]]), ["a"]) + assert list(df.index) == ["a"] + assert df.loc["a", "avg_reward"] == 0.5 + + +def test_verify_report_agent_mismatch(): + with pytest.raises(ValueError, match="do not match"): + repro._verify_report(_report([["a", 0.5, 0.1, 0, "4/4"]]), ["b"]) + + +def test_verify_report_duplicate_agent_names(): + with pytest.raises(ValueError, match="Duplicate agent names"): + repro._verify_report(_report([["a", 0.5, 0.1, 0, "4/4"]]), ["a", "a"]) + + +@pytest.mark.parametrize( + "n_err, n_completed, match", + [(2, "4/4", "has 2 errors"), (0, "3/4", "has 3 completed tasks out of 4")], +) +def test_verify_report_errors_and_incomplete(n_err, n_completed, match, caplog): + report = _report([["a", 0.5, 0.1, n_err, n_completed]]) + with pytest.raises(ValueError, match=match): + repro._verify_report(report, ["a"]) + with caplog.at_level(logging.WARNING): + repro._verify_report(report, ["a"], strict_reproducibility=False) + assert match in caplog.text + + +# --------------------------------------------------------------------------- +# csv helpers +# --------------------------------------------------------------------------- + + +def test_get_csv_headers(tmp_path): + empty = tmp_path / "empty.csv" + empty.write_text("") + assert repro._get_csv_headers(empty) is None + + full = tmp_path / "full.csv" + full.write_text("h1,h2\n1,2\n") + assert repro._get_csv_headers(full) == ["h1", "h2"] + + +def test_add_result_to_info_unwraps_numpy_scalars(): + report = _report([["a", 0.75, 0.2, 1, "3/4"]]) + info = {"agent_name": "a"} + repro._add_result_to_info(info, report) + assert info == { + "agent_name": "a", + "avg_reward": 0.75, + "std_err": 0.2, + "n_err": 1, + "n_completed": "3/4", + } + assert type(info["n_err"]) is int + assert type(info["avg_reward"]) is float + + +# --------------------------------------------------------------------------- +# append_to_journal +# --------------------------------------------------------------------------- + + +def _read_csv(path): + with open(path, newline="") as f: + return list(csv.reader(f)) + + +def test_append_to_journal_creates_header_and_appends(tmp_path): + journal = tmp_path / "journal.csv" + info = {"git_user": "me", "agent_names": ["a", "b"], "benchmark": "bench"} + report = _report([["a", 0.5, 0.1, 0, "4/4"], ["b", 0.25, 0.1, 0, "4/4"]]) + + repro.append_to_journal(info, report, journal_path=journal) + rows = _read_csv(journal) + assert rows[0] == ["git_user", "agent_name", "benchmark"] + assert rows[1:] == [["me", "a", "bench"], ["me", "b", "bench"]] + + repro.append_to_journal(info, report, journal_path=journal) + rows = _read_csv(journal) + assert len(rows) == 5 + assert rows.count(["git_user", "agent_name", "benchmark"]) == 1 + assert info["agent_names"] == ["a", "b"] + + +def test_append_to_journal_writes_results_with_existing_headers(tmp_path): + journal = tmp_path / "journal.csv" + journal.write_text("agent_name,benchmark,avg_reward,std_err,n_err,n_completed\n") + info = {"agent_names": ["a"], "benchmark": "bench"} + report = _report([["a", 0.75, 0.217, 0, "4/4"]]) + repro.append_to_journal(info, report, journal_path=journal) + assert _read_csv(journal)[1] == ["a", "bench", "0.75", "0.217", "0", "4/4"] + + +@pytest.mark.xfail( + strict=True, + reason="BUG: a newly created journal takes headers from info only, so results are dropped", +) +def test_append_to_journal_new_file_includes_results(tmp_path): + journal = tmp_path / "journal.csv" + info = {"agent_names": ["a"], "benchmark": "bench"} + report = _report([["a", 0.75, 0.217, 0, "4/4"]]) + repro.append_to_journal(info, report, journal_path=journal) + header = _read_csv(journal)[0] + assert {"avg_reward", "std_err", "n_err", "n_completed"} <= set(header) + + +def test_append_to_journal_length_mismatch(tmp_path): + info = {"agent_names": ["a", "b"]} + with pytest.raises(ValueError, match="Mismatch between the number of agents"): + repro.append_to_journal( + info, _report([["a", 0.5, 0.1, 0, "4/4"]]), journal_path=tmp_path / "j.csv" + ) + assert not (tmp_path / "j.csv").exists() + + +def test_append_to_journal_strict_incomplete_raises_before_writing(tmp_path): + journal = tmp_path / "j.csv" + info = {"agent_names": ["a"], "benchmark": "x"} + report = _report([["a", 0.5, 0.1, 1, "3/4"]]) + with pytest.raises(ValueError): + repro.append_to_journal(info, report, journal_path=journal) + assert not journal.exists() + repro.append_to_journal(info, report, journal_path=journal, strict_reproducibility=False) + assert journal.exists() + + +def test_append_to_journal_default_path_in_git_clone(tmp_path, monkeypatch): + fake_pkg_init = tmp_path / "root" / "src" / "agentlab" / "__init__.py" + fake_pkg_init.parent.mkdir(parents=True) + monkeypatch.setattr(repro, "agentlab", SimpleNamespace(__file__=str(fake_pkg_init))) + monkeypatch.setattr(repro, "_get_repo", lambda module: "repo") + info = {"agent_names": ["a"], "benchmark": "x"} + repro.append_to_journal(info, _report([["a", 0.5, 0.1, 0, "4/4"]])) + assert (tmp_path / "root" / "reproducibility_journal.csv").exists() + + +def test_append_to_journal_default_path_without_git(tmp_path, monkeypatch, caplog): + def raise_invalid(module): + raise InvalidGitRepositoryError("no") + + monkeypatch.setattr(repro, "_get_repo", raise_invalid) + monkeypatch.setattr(repro, "RESULTS_DIR", tmp_path) + info = {"agent_names": ["a"], "benchmark": "x"} + with caplog.at_level(logging.WARNING): + repro.append_to_journal(info, _report([["a", 0.5, 0.1, 0, "4/4"]])) + assert (tmp_path / "reproducibility_journal.csv").exists() + assert "Could not find a git repository" in caplog.text diff --git a/tests/experiments/test_study_unit.py b/tests/experiments/test_study_unit.py new file mode 100644 index 000000000..02ad289d9 --- /dev/null +++ b/tests/experiments/test_study_unit.py @@ -0,0 +1,730 @@ +import gzip +import logging +import pickle +import queue +import random +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import bgym +import pandas as pd +import pytest + +from agentlab.agents.generic_agent.agent_configs import FLAGS_GPT_4o +from agentlab.agents.generic_agent.generic_agent import GenericAgentArgs +from agentlab.analyze import inspect_results +from agentlab.benchmarks.abstract_env import AbstractEnvArgs +from agentlab.experiments import study as study_mod +from agentlab.experiments.loop import EnvArgs, ExpArgs +from agentlab.experiments.multi_server import BaseServer +from agentlab.experiments.study import ( + AbstractStudy, + ParallelStudies, + ParallelStudies_alt, + SequentialStudies, + Study, + _convert_env_args, + _init_worker, + _make_study_name, + _run_study, + get_most_recent_study, + make_study, + set_demo_mode, +) +from agentlab.llm.chat_api import CheatMiniWoBLLMArgs + +BENCH = "miniwob_tiny_test" + + +def _agent(suffix=""): + agent = GenericAgentArgs(chat_model_args=CheatMiniWoBLLMArgs(), flags=deepcopy(FLAGS_GPT_4o)) + agent.agent_name = agent.agent_name + suffix + return agent + + +def _benchmark(): + return bgym.DEFAULT_BENCHMARKS[BENCH]() + + +@pytest.fixture +def study(tmp_path): + s = Study([_agent()], _benchmark()) + s.dir = tmp_path / "study_dir" + return s + + +# --------------------------------------------------------------------------- +# make_study +# --------------------------------------------------------------------------- + + +def test_make_study_single_agent_from_benchmark_name(): + s = make_study(_agent(), BENCH.upper(), suffix="sfx", comment="c", ignore_dependencies=True) + assert isinstance(s, Study) + assert s.benchmark.name == BENCH + assert (s.suffix, s.comment, s.ignore_dependencies) == ("sfx", "c", True) + assert len(s.exp_args_list) == 4 + + +def test_make_study_multiple_agents_non_webarena_single_study(): + s = make_study([_agent("_0"), _agent("_1")], _benchmark()) + assert isinstance(s, Study) + assert len(s.exp_args_list) == 8 + assert [e.order for e in s.exp_args_list] == list(range(8)) + + +class _RecordingStudy: + def __init__(self, agent_args, benchmark, **kwargs): + self.agent_args = agent_args + self.benchmark = benchmark + self.kwargs = kwargs + + +@pytest.mark.parametrize( + "parallel_servers, expected_cls", + [(None, SequentialStudies), (2, ParallelStudies)], +) +def test_make_study_webarena_splits_agents(monkeypatch, parallel_servers, expected_cls): + monkeypatch.setattr(study_mod, "Study", _RecordingStudy) + agents = ["agent_a", "agent_b"] + bench = SimpleNamespace(name="webarena_fake") + result = make_study(agents, bench, suffix="s", parallel_servers=parallel_servers) + assert type(result) is expected_cls + assert [st.agent_args for st in result.studies] == [["agent_a"], ["agent_b"]] + assert all(st.kwargs["suffix"] == "s" for st in result.studies) + if parallel_servers is not None: + assert result.parallel_servers == 2 + + +def test_make_study_parallel_servers_on_non_webarena(monkeypatch): + monkeypatch.setattr(study_mod, "Study", _RecordingStudy) + result = make_study(["a", "b"], SimpleNamespace(name="miniwob"), parallel_servers=[1, 2]) + assert isinstance(result, ParallelStudies) + assert result.parallel_servers == [1, 2] + + +# --------------------------------------------------------------------------- +# Study construction / agents_on_benchmark +# --------------------------------------------------------------------------- + + +def test_study_post_init(tmp_path): + s = Study([_agent()], BENCH, dir=str(tmp_path)) + assert s.dir == tmp_path + assert s.uuid is not None + assert all(isinstance(ea, EnvArgs) for ea in s.benchmark.env_args_list) + assert all(isinstance(e, ExpArgs) for e in s.exp_args_list) + assert {e.env_args.task_name for e in s.exp_args_list} == { + ea.task_name for ea in s.benchmark.env_args_list + } + other = Study([_agent()], BENCH) + assert other.uuid != s.uuid + + +def test_study_demo_mode_applies_to_env_and_agent(): + s = Study([_agent()], _benchmark(), demo_mode=True) + for exp in s.exp_args_list: + assert exp.env_args.record_video is True + assert exp.env_args.slow_mo == 1000 + assert s.agent_args[0].flags.action.action_set.demo_mode == "all_blue" + + +def test_agents_on_benchmark_uses_dependencies(monkeypatch, study): + calls = [] + + def fake_add_dependencies(exp_args_list, graph): + calls.append(graph) + return exp_args_list[::-1] + + monkeypatch.setattr(study_mod, "add_dependencies", fake_add_dependencies) + result = study.agents_on_benchmark(_agent(), _benchmark(), logging_level=logging.ERROR) + assert len(calls) == 1 + assert set(calls[0]) == {"miniwob.click-checkboxes", "miniwob.click-dialog"} + assert [e.order for e in result] == [3, 2, 1, 0] + assert all(e.logging_level == logging.ERROR for e in result) + + +def test_agents_on_benchmark_ignore_dependencies_warns(monkeypatch, study, caplog): + monkeypatch.setattr(study_mod, "add_dependencies", mock.Mock(side_effect=AssertionError)) + with caplog.at_level(logging.WARNING, logger=study_mod.logger.name): + result = study.agents_on_benchmark([_agent()], _benchmark(), ignore_dependencies=True) + assert len(result) == 4 + assert "Ignoring dependencies" in caplog.text + + +@pytest.mark.parametrize("name", ["webarena_tiny", "visualwebarena"]) +def test_agents_on_benchmark_rejects_multiple_agents_on_webarena(study, name): + with pytest.raises(ValueError, match="Only one agent"): + study.agents_on_benchmark([_agent("_0"), _agent("_1")], SimpleNamespace(name=name)) + + +def test_set_demo_mode(): + envs = [SimpleNamespace(), SimpleNamespace()] + set_demo_mode(envs) + for env in envs: + assert env.viewport == {"width": 1280, "height": 720} + assert env.record_video is True + assert env.wait_for_user_message is False + assert env.slow_mo == 1000 + + +@dataclass +class _CustomEnvArgs(AbstractEnvArgs): + task_name: str = "custom" + + def make_env(self, action_mapping, exp_dir, exp_task_kwargs): + return None + + +def test_convert_env_args(): + new = EnvArgs(task_name="t1") + custom = _CustomEnvArgs() + old = bgym.EnvArgs(task_name="t2", max_steps=7) + result = _convert_env_args([new, custom, old]) + assert result[0] is new + assert result[1] is custom + assert type(result[2]) is EnvArgs + assert (result[2].task_name, result[2].max_steps) == ("t2", 7) + + +def test_convert_env_args_rejects_unknown_type(): + with pytest.raises(TypeError, match="Unexpected type"): + _convert_env_args([{"task_name": "x"}]) + + +# --------------------------------------------------------------------------- +# naming +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "agents, benchmarks, suffix, expected", + [ + (["A"], ["b"], None, "a-on-b"), + (["A", "A"], ["b"], "v1", "a-on-b-v1"), + (["A", "B"], ["b"], "", "2-agents-on-b"), + (["A"], ["b1", "b2", "b2"], "x", "a-on-2-benchmarks-x"), + ], +) +def test_make_study_name(agents, benchmarks, suffix, expected): + assert _make_study_name(agents, benchmarks, suffix) == expected + + +def test_make_study_name_truncates_long_names(): + assert len(_make_study_name(["a" * 300], ["b"])) <= 200 + + +def test_study_name_property(study): + study.suffix = "trial" + assert study.name == "genericagent-test-cheat-miniwob-click-test-on-miniwob-tiny-test-trial" + + +def test_override_max_steps(study): + study.override_max_steps(3) + assert {e.env_args.max_steps for e in study.exp_args_list} == {3} + + +def test_shuffle_exps_keeps_same_experiments(study): + random.seed(1234) + before = list(study.exp_args_list) + study.shuffle_exps() + assert len(study.exp_args_list) == len(before) + assert {id(e) for e in study.exp_args_list} == {id(e) for e in before} + + +# --------------------------------------------------------------------------- +# dir / save / load +# --------------------------------------------------------------------------- + + +def test_make_dir_creates_dated_dir(tmp_path): + s = Study([_agent()], _benchmark()) + s.make_dir(exp_root=tmp_path) + assert s.dir.parent == tmp_path + assert s.dir.is_dir() + assert s.dir.name.endswith(s.name) + first = s.dir + s.make_dir(exp_root=tmp_path / "other") + assert s.dir == first + + +def test_save_and_load_roundtrip(study, tmp_path): + study.save() + assert (study.dir / "study.pkl.gz").exists() + moved = tmp_path / "moved" + (study.dir / "study.pkl.gz").rename(moved.with_suffix(".tmp")) + moved.mkdir() + moved.with_suffix(".tmp").rename(moved / "study.pkl.gz") + + loaded = Study.load(str(moved)) + assert loaded.uuid == study.uuid + assert loaded.dir == moved + assert len(loaded.exp_args_list) == 4 + assert loaded.agent_args[0].agent_name == study.agent_args[0].agent_name + + +def test_load_backward_compat_without_pickle(tmp_path, monkeypatch): + agent = _agent() + fake_result = SimpleNamespace( + exp_args=SimpleNamespace( + env_args=SimpleNamespace(task_name=f"{BENCH}.click-dialog"), agent_args=agent + ) + ) + seen = {} + + def fake_yield(savedir_base, progress_fn=None): + seen["dir"] = savedir_base + yield fake_result + + monkeypatch.setattr(inspect_results, "yield_all_exp_results", fake_yield) + loaded = Study.load(tmp_path) + assert seen["dir"] == tmp_path + assert loaded.benchmark.name == BENCH + assert loaded.dir == tmp_path + assert len(loaded.exp_args_list) == 4 + + +def test_get_most_recent_study(tmp_path, monkeypatch): + for name in [ + "2024-01-01_10-00-00_alpha", + "2024-06-01_10-00-00_beta", + "_2030-01-01_10-00-00_hidden", + "not_a_date", + ]: + (tmp_path / name).mkdir() + (tmp_path / "2031-01-01_10-00-00_file.txt").write_text("") + + assert get_most_recent_study(tmp_path).name == "2024-06-01_10-00-00_beta" + assert get_most_recent_study(tmp_path, contains="alpha").name == "2024-01-01_10-00-00_alpha" + assert get_most_recent_study(tmp_path, contains="nothing") is None + + monkeypatch.setattr(study_mod, "RESULTS_DIR", tmp_path) + assert get_most_recent_study().name == "2024-06-01_10-00-00_beta" + + +def test_load_most_recent(tmp_path): + older = Study([_agent()], _benchmark(), dir=tmp_path / "2024-01-01_00-00-00_older") + newer = Study([_agent()], _benchmark(), dir=tmp_path / "2024-02-01_00-00-00_newer") + older.save() + newer.save() + assert Study.load_most_recent(tmp_path).uuid == newer.uuid + assert Study.load_most_recent(tmp_path, contains="older").uuid == older.uuid + + +# --------------------------------------------------------------------------- +# results / incomplete / journal +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_inspect(monkeypatch): + result_df = pd.DataFrame({"x": [1, 2]}) + summary_df = pd.DataFrame({"avg_reward": [0.5]}) + monkeypatch.setattr(inspect_results, "load_result_df", lambda d: result_df) + monkeypatch.setattr(inspect_results, "error_report", lambda df, **kw: "ERR REPORT") + monkeypatch.setattr(inspect_results, "summarize_study", lambda df: summary_df) + return result_df, summary_df + + +@pytest.mark.parametrize("suffix, file_suffix", [("", ""), ("trial_1", "_trial_1")]) +def test_get_results_saves_files(study, fake_inspect, suffix, file_suffix): + study.make_dir() + result_df, summary_df, report = study.get_results(suffix=suffix) + assert result_df is fake_inspect[0] + assert summary_df is fake_inspect[1] + assert report == "ERR REPORT" + assert (study.dir / f"result_df{file_suffix}.csv").exists() + assert (study.dir / f"summary_df{file_suffix}.csv").exists() + assert (study.dir / f"error_report{file_suffix}.md").read_text() == "ERR REPORT" + + +def test_get_results_without_saving(study, fake_inspect): + study.make_dir() + study.get_results(also_save=False) + assert list(study.dir.iterdir()) == [] + + +def test_find_incomplete_counts_incomplete_and_errors(monkeypatch, study): + exp_list = [ + SimpleNamespace(is_dummy=False, status="error"), + SimpleNamespace(is_dummy=False), + SimpleNamespace(is_dummy=True, status="done"), + ] + fake = mock.Mock(return_value=exp_list) + monkeypatch.setattr(study_mod, "find_incomplete", fake) + assert study.find_incomplete(include_errors=False) == (2, 1) + fake.assert_called_once_with(study.dir, include_errors=False) + assert study.exp_args_list is exp_list + + +def test_load_exp_args_list(monkeypatch, study): + monkeypatch.setattr( + inspect_results, "yield_all_exp_results", lambda savedir_base: iter(["e1", "e2"]) + ) + study.load_exp_args_list() + assert study.exp_args_list == ["e1", "e2"] + + +def test_append_to_journal_delegates(monkeypatch, study): + summary = pd.DataFrame({"a": [1]}) + monkeypatch.setattr(Study, "get_results", lambda self: (None, summary, None)) + fake = mock.Mock() + monkeypatch.setattr(study_mod.repro, "append_to_journal", fake) + study.reproducibility_info = {"k": "v"} + study.append_to_journal(strict_reproducibility=False) + fake.assert_called_once_with({"k": "v"}, summary, strict_reproducibility=False) + + +# --------------------------------------------------------------------------- +# reproducibility info +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_repro_info(monkeypatch): + calls = [] + infos = [] + + def fake_get_info(agent_names, benchmark, study_id, **kwargs): + calls.append( + dict(agent_names=agent_names, benchmark=benchmark, study_id=study_id, **kwargs) + ) + return infos.pop(0) if infos else {"agent_names": agent_names, "v": 1} + + monkeypatch.setattr(study_mod.repro, "get_reproducibility_info", fake_get_info) + return calls, infos + + +@pytest.mark.parametrize("strict", [True, False]) +def test_set_reproducibility_info_arguments(study, fake_repro_info, strict): + calls, _ = fake_repro_info + study.set_reproducibility_info(strict_reproducibility=strict, comment="note") + call = calls[0] + assert call["agent_names"] == [study.agent_args[0].agent_name] + assert call["benchmark"] is study.benchmark + assert call["study_id"] == study.uuid + assert call["ignore_changes"] is (not strict) + assert call["allow_bypass_benchmark_version"] is (not strict) + assert call["comment"] == "note" + assert study.reproducibility_info["v"] == 1 + + +def test_set_reproducibility_info_incompatible_strict_raises(study, fake_repro_info): + _, infos = fake_repro_info + study.reproducibility_info = {"v": 1, "date": "old"} + infos.append({"v": 2, "date": "new"}) + with pytest.raises(ValueError, match="Key v has changed"): + study.set_reproducibility_info(strict_reproducibility=True) + assert study.reproducibility_info == {"v": 1, "date": "old"} + + +def test_set_reproducibility_info_incompatible_lenient_replaces(study, fake_repro_info, caplog): + _, infos = fake_repro_info + study.reproducibility_info = {"v": 1} + infos.append({"v": 2}) + with caplog.at_level(logging.WARNING): + study.set_reproducibility_info(strict_reproducibility=False) + assert study.reproducibility_info == {"v": 2} + assert "Key v has changed" in caplog.text + + +# --------------------------------------------------------------------------- +# run / _run +# --------------------------------------------------------------------------- + + +@pytest.fixture +def run_harness(monkeypatch, fake_repro_info): + state = SimpleNamespace(run_calls=[], suffixes=[], incomplete=[], prepared=0) + + def fake_run_experiments(n_jobs, exp_args_list, study_dir, parallel_backend, avg_step_timeout): + state.run_calls.append((n_jobs, len(exp_args_list), study_dir, parallel_backend)) + + def fake_prepare(self): + state.prepared += 1 + + def fake_get_results(self, suffix="", also_save=True): + state.suffixes.append(suffix) + return None, "summary", "error report text" + + def fake_find_incomplete(self, include_errors=True): + state.include_errors = include_errors + return state.incomplete.pop(0) + + monkeypatch.setattr(study_mod, "run_experiments", fake_run_experiments) + monkeypatch.setattr(bgym.Benchmark, "prepare_backends", fake_prepare) + monkeypatch.setattr(Study, "get_results", fake_get_results) + monkeypatch.setattr(Study, "find_incomplete", fake_find_incomplete) + return state + + +@pytest.mark.parametrize( + "incomplete, n_trials, log_msg", + [ + ([(0, 0)], 1, "finished"), + ([(2, 2)], 1, "More than 30%"), + ([(2, 1), (1, 0), (1, 0)], 3, "did not finish after 3 trials"), + ], +) +def test_run_relaunch_loop(study, run_harness, caplog, incomplete, n_trials, log_msg): + run_harness.incomplete = list(incomplete) + with caplog.at_level(logging.INFO, logger=study_mod.logger.name): + study.run(n_jobs=2, parallel_backend="joblib", n_relaunch=3, relaunch_errors=False) + assert len(run_harness.run_calls) == n_trials + assert run_harness.run_calls[0] == (2, 4, study.dir, "joblib") + assert run_harness.prepared == n_trials + assert run_harness.suffixes == [f"trial_{i + 1}_of_3" for i in range(n_trials)] + assert run_harness.include_errors is False + assert (study.dir / "study.pkl.gz").exists() + assert study.reproducibility_info is not None + assert log_msg in caplog.text + assert "error report text" in caplog.text + + +@pytest.mark.xfail( + strict=True, + reason="BUG: Study.run never updates last_error_count, so the no-progress stop never fires", +) +def test_run_stops_when_errors_do_not_decrease(study, run_harness): + run_harness.incomplete = [(1, 1), (1, 1), (1, 1)] + study.run(n_relaunch=3) + assert len(run_harness.run_calls) == 2 + + +def test_run_saved_study_is_loadable(study, run_harness): + run_harness.incomplete = [(0, 0)] + study.run(n_relaunch=1) + with gzip.open(study.dir / "study.pkl.gz", "rb") as f: + loaded = pickle.load(f) + assert loaded.uuid == study.uuid + assert loaded.reproducibility_info == study.reproducibility_info + + +def test_private_run_requires_exp_args_list(study): + study.exp_args_list = None + with pytest.raises(ValueError, match="exp_args_list is None"): + study._run() + + +def test_private_run_passes_avg_step_timeout(study, monkeypatch): + fake = mock.Mock() + monkeypatch.setattr(study_mod, "run_experiments", fake) + monkeypatch.setattr(bgym.Benchmark, "prepare_backends", lambda self: None) + study.avg_step_timeout = 7 + study._run(n_jobs=3, parallel_backend="sequential") + fake.assert_called_once_with( + 3, study.exp_args_list, study.dir, parallel_backend="sequential", avg_step_timeout=7 + ) + + +# --------------------------------------------------------------------------- +# SequentialStudies / ParallelStudies +# --------------------------------------------------------------------------- + + +@pytest.fixture +def two_studies(): + return [Study([_agent("_0")], _benchmark()), Study([_agent("_1")], _benchmark())] + + +@pytest.fixture +def recorded_study_runs(monkeypatch): + calls = [] + + def fake_run( + self, n_jobs=1, parallel_backend="ray", strict_reproducibility=False, n_relaunch=3 + ): + calls.append( + ( + self.agent_args[0].agent_name, + n_jobs, + parallel_backend, + strict_reproducibility, + n_relaunch, + ) + ) + + monkeypatch.setattr(Study, "run", fake_run) + return calls + + +def test_sequential_studies_name(two_studies): + seq = SequentialStudies(two_studies) + assert seq.name == "2-agents-on-miniwob-tiny-test" + seq.suffix = "abc" + assert seq.name.endswith("-abc") + + +def test_sequential_studies_delegation(monkeypatch, two_studies): + seq = SequentialStudies(two_studies) + find_calls, journal_calls = [], [] + monkeypatch.setattr( + Study, + "find_incomplete", + lambda self, include_errors=True: find_calls.append(include_errors), + ) + monkeypatch.setattr( + Study, + "append_to_journal", + lambda self, strict_reproducibility=True: journal_calls.append(strict_reproducibility), + ) + seq.find_incomplete(include_errors=False) + seq.append_to_journal(strict_reproducibility=False) + seq.override_max_steps(5) + assert find_calls == [False, False] + assert journal_calls == [False, False] + assert {e.env_args.max_steps for s in two_studies for e in s.exp_args_list} == {5} + + +def _patch_make_dir_default(monkeypatch, path): + monkeypatch.setattr(AbstractStudy.make_dir, "__defaults__", (path,)) + + +def test_sequential_studies_run(monkeypatch, tmp_path, two_studies, recorded_study_runs): + _patch_make_dir_default(monkeypatch, tmp_path / "default_root") + monkeypatch.setattr(SequentialStudies, "get_results", lambda self: (None, pd.DataFrame(), "")) + seq = SequentialStudies(two_studies) + seq.run( + n_jobs=2, + parallel_backend="joblib", + strict_reproducibility=True, + n_relaunch=1, + exp_root=tmp_path / "root", + ) + assert (seq.dir / "study.pkl.gz").exists() + for st in two_studies: + assert st.dir.parent == seq.dir + assert [c[1:] for c in recorded_study_runs] == [(2, "joblib", True, 1)] * 2 + assert [c[0] for c in recorded_study_runs] == [ + two_studies[0].agent_args[0].agent_name, + two_studies[1].agent_args[0].agent_name, + ] + + +@pytest.mark.xfail( + strict=True, reason="BUG: SequentialStudies.run ignores exp_root and uses RESULTS_DIR" +) +def test_sequential_studies_run_respects_exp_root( + monkeypatch, tmp_path, two_studies, recorded_study_runs +): + _patch_make_dir_default(monkeypatch, tmp_path / "default_root") + monkeypatch.setattr(SequentialStudies, "get_results", lambda self: (None, pd.DataFrame(), "")) + seq = SequentialStudies(two_studies) + seq.run(exp_root=tmp_path / "root") + assert seq.dir.parent == tmp_path / "root" + + +class _FakeFuture: + def __init__(self, fn, args): + self.exc = None + try: + self.value = fn(*args) + except Exception as e: # noqa: BLE001 + self.exc = e + + def result(self): + if self.exc is not None: + raise self.exc + return self.value + + +class _FakeExecutor: + instances = [] + + def __init__(self, max_workers, initializer, initargs): + self.max_workers = max_workers + _FakeExecutor.instances.append(self) + for _ in range(max_workers): + initializer(*initargs) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def submit(self, fn, *args): + return _FakeFuture(fn, args) + + +class _FakePool: + instances = [] + + def __init__(self, processes, initializer, initargs): + self.processes = processes + _FakePool.instances.append(self) + for _ in range(processes): + initializer(*initargs) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def starmap(self, fn, iterable): + return [fn(*args) for args in iterable] + + +@pytest.fixture +def fake_mp(monkeypatch): + _FakeExecutor.instances = [] + _FakePool.instances = [] + monkeypatch.setattr(study_mod, "Manager", lambda: SimpleNamespace(Queue=queue.Queue)) + monkeypatch.setattr(study_mod, "ProcessPoolExecutor", _FakeExecutor) + monkeypatch.setattr(study_mod, "Pool", _FakePool) + + +class _Server(BaseServer): + def __init__(self): + self.init_count = 0 + + def init(self): + self.init_count += 1 + + +@pytest.mark.parametrize( + "cls, fake", [(ParallelStudies, _FakeExecutor), (ParallelStudies_alt, _FakePool)] +) +def test_parallel_studies_int_servers(fake_mp, two_studies, recorded_study_runs, cls, fake): + ps = cls(two_studies, parallel_servers=3) + ps._run(n_jobs=4, parallel_backend="ray", strict_reproducibility=False, n_relaunch=2) + workers = fake.instances[0] + assert getattr(workers, "max_workers", getattr(workers, "processes", None)) == 3 + assert sorted(c[1:] for c in recorded_study_runs) == [(4, "ray", False, 2)] * 2 + + +@pytest.mark.parametrize("cls", [ParallelStudies, ParallelStudies_alt]) +def test_parallel_studies_initializes_each_server(fake_mp, two_studies, recorded_study_runs, cls): + servers = [_Server(), _Server()] + cls(two_studies, parallel_servers=servers)._run() + assert [s.init_count for s in servers] == [1, 1] + assert len(recorded_study_runs) == 2 + + +def test_parallel_studies_propagates_study_errors(fake_mp, monkeypatch, two_studies): + def failing_run(self, *args, **kwargs): + raise RuntimeError("study failed") + + monkeypatch.setattr(Study, "run", failing_run) + with pytest.raises(RuntimeError, match="study failed"): + ParallelStudies(two_studies, parallel_servers=1)._run() + + +def test_init_worker_pulls_and_inits_server(capsys): + q = queue.Queue() + server = _Server() + q.put(server) + _init_worker(q) + assert server.init_count == 1 + assert q.empty() + assert "initializing server instance" in capsys.readouterr().out + + +def test_run_study_forwards_arguments(): + fake_study = mock.Mock() + _run_study(fake_study, 2, "joblib", True, 5) + fake_study.run.assert_called_once_with(2, "joblib", True, 5) diff --git a/tests/experiments/test_view_dep_graph.py b/tests/experiments/test_view_dep_graph.py new file mode 100644 index 000000000..5aa3837b3 --- /dev/null +++ b/tests/experiments/test_view_dep_graph.py @@ -0,0 +1,176 @@ +import importlib +import sys +from unittest import mock + +import matplotlib + +matplotlib.use("Agg") + +import bgym # noqa: E402 +import matplotlib.pyplot as plt # noqa: E402 +import networkx as nx # noqa: E402 +import pytest # noqa: E402 + +MODULE = "agentlab.experiments.view_dep_graph" + +FAKE_DEPS = { + "visualwebarena.1": [], + "visualwebarena.2": ["visualwebarena.1"], + "visualwebarena.3": ["visualwebarena.2"], + "visualwebarena.7": ["visualwebarena.3"], + "visualwebarena.9": [], + "visualwebarena.10": ["visualwebarena.9"], +} + + +class FakeBenchmark: + instances = 0 + + def __init__(self): + FakeBenchmark.instances += 1 + + def dependency_graph_over_tasks(self): + return dict(FAKE_DEPS) + + +@pytest.fixture(scope="module") +def import_result(): + """Import the script-like module with a fake benchmark so its top-level code runs offline.""" + FakeBenchmark.instances = 0 + with pytest.MonkeyPatch.context() as mp: + mp.delitem(sys.modules, MODULE, raising=False) + mp.setitem(bgym.DEFAULT_BENCHMARKS, "visualwebarena", FakeBenchmark) + show = mock.Mock() + mp.setattr(plt, "show", show) + module = importlib.import_module(MODULE) + n_figs = len(plt.get_fignums()) + instances = FakeBenchmark.instances + plt.close("all") + return module, show, n_figs, instances + + +@pytest.fixture +def vdg(import_result): + yield import_result[0] + plt.close("all") + + +def test_module_script_runs_with_benchmark_graph(import_result): + _, show, n_figs, instances = import_result + assert instances == 1 + show.assert_called_once() + assert n_figs == 1 + + +def test_clean_dict_strips_benchmark_prefix(vdg): + assert vdg.clean_dict({"a.b.1": ["a.b.2", "x.3"], "4": []}) == {"1": ["2", "3"], "4": []} + + +def test_dict_to_networkx_edges_point_from_dependency_to_task(vdg): + G = vdg.dict_to_networkx({"2": ["1"], "3": ["1", "2"], "lonely": []}) + assert isinstance(G, nx.DiGraph) + assert set(G.edges) == {("1", "2"), ("1", "3"), ("2", "3")} + # nodes without any dependency relation are never added + assert "lonely" not in G + + +def test_dict_to_networkx_many_nodes(vdg): + deps = {str(i): [str(i - 1)] for i in range(1, 25)} + G = vdg.dict_to_networkx(deps) + assert G.number_of_nodes() == 25 + assert G.number_of_edges() == 24 + + +@pytest.mark.parametrize( + "deps, expected", + [ + ( + {"1": [], "2": ["1"], "3": ["2"], "5": []}, + {"1-3": [], "5": []}, + ), + ( + {"1": [], "2": ["1"], "3": ["2"], "10": ["3"]}, + {"1-3": [], "10": ["1-3"]}, + ), + ( + # node 1 has two successors -> no chain + {"1": [], "2": ["1"], "3": ["1"]}, + {"1": [], "2": ["1"], "3": ["1"]}, + ), + ( + {"1": [], "2": ["1"], "4": [], "5": ["4"], "6": ["5"]}, + {"1-2": [], "4-6": []}, + ), + ( + # chain starting after an external dependency keeps it + {"1": [], "5": ["1"], "6": ["5"], "7": ["6"]}, + {"5-7": ["1"], "1": []}, + ), + ], +) +def test_compress_sequential_chains(vdg, deps, expected): + assert vdg.compress_sequential_chains(deps) == expected + + +def test_compress_chains_linear_graph_collapses_fully(vdg): + G = nx.DiGraph([("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]) + compressed = vdg.compress_chains(G) + assert list(compressed.nodes) == ["a,b,c,d,e"] + assert G.number_of_nodes() == 5 # original untouched + + +def test_compress_chains_keeps_external_edges(vdg): + G = nx.DiGraph([("root1", "x"), ("root2", "x"), ("x", "a"), ("a", "b"), ("b", "c")]) + compressed = vdg.compress_chains(G) + assert "x,a,b" not in compressed # x has two predecessors + assert set(compressed.nodes) == {"root1", "root2", "x", "a,b,c"} + assert set(compressed.edges) == {("root1", "x"), ("root2", "x"), ("x", "a,b,c")} + + +def test_compress_chains_branching_graph_unchanged(vdg): + G = nx.DiGraph([("x", "y"), ("x", "z")]) + compressed = vdg.compress_chains(G) + assert compressed is not G + assert set(compressed.edges) == set(G.edges) + + +def test_plot_graph_sizes_nodes_by_range_and_sets_title(vdg): + G = nx.DiGraph([("1-3", "5")]) + fig, ax = plt.subplots() + with mock.patch.object(vdg.nx, "draw") as draw: + vdg.plot_graph(G, ax, title="My Title", node_size=40) + sizes = dict(zip(G.nodes, draw.call_args.kwargs["node_size"])) + assert sizes == {"1-3": 12.0, "5": 4.0} + assert draw.call_args.kwargs["ax"] is ax + assert ax.get_title() == "My Title" + assert not ax.axison + + +def test_plot_graph_real_draw_without_title(vdg): + G = nx.DiGraph([("1", "2"), ("2", "3")]) + fig, ax = plt.subplots() + vdg.plot_graph(G, ax) + assert ax.get_title() == "" + assert len(ax.collections) > 0 + + +def test_plot_components_grid_empty(vdg, capsys): + assert vdg.plot_components_grid([]) is None + assert "No components found" in capsys.readouterr().out + + +def _components(n): + return [nx.DiGraph([(f"{i}a", f"{i}b")]) for i in range(n)] + + +@pytest.mark.parametrize( + "n_components, max_cols, n_axes", + [(1, 4, 1), (3, 4, 3), (2, 1, 2), (3, 2, 3), (4, 2, 4)], +) +def test_plot_components_grid_layouts(vdg, n_components, max_cols, n_axes): + with mock.patch.object(vdg.nx, "draw"): + fig = vdg.plot_components_grid(_components(n_components), max_cols=max_cols) + assert len(fig.axes) == n_axes + titles = [ax.get_title() for ax in fig.axes] + assert titles == [f"Component {i + 1} (2 nodes)" for i in range(n_components)] + assert fig._suptitle.get_text() == "Dependency Graph Components"