File size: 7,082 Bytes
5146e76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
"""Held-out partner catalog.

Reads ``evaluation/configs/global_heldout_settings.yaml`` as the source
of truth so the benchmark_ui leaderboard and the training pipeline
agree on which agents count as "held-out". Versioning is per (env,
version_tag) tuples.

For envs whose adapters are already registered (Hanabi, LBF, Mock),
this module maps yaml partner entries to callables by delegating to
``EnvRenderer.available_partners()``. That keeps one source of truth
for partner construction (the env adapter) and lets the yaml add
bounds / inclusion-per-version metadata on top.

Version discovery:
  Today the yaml is unversioned. We treat each env's block as
  version "v1" by convention. When Caroline adds an explicit ``version:``
  field we'll honor it here.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

from .envs import get as get_env, registry as env_registry

log = logging.getLogger(__name__)

_REPO_ROOT = Path(__file__).resolve().parents[2]
_HELDOUT_YAML = _REPO_ROOT / "evaluation" / "configs" / "global_heldout_settings.yaml"


@dataclass
class HeldoutPartner:
    """One held-out partner as far as the leaderboard cares.

    ``load_get_action`` returns the ``(obs, state, rng) -> action``
    callable the eval loop needs. We delegate to the env adapter's
    partner spec when possible so we aren't duplicating construction
    logic (SmartBot card_counts, OBL weight paths, etc.).
    """
    env_name: str
    version: str
    key: str
    display_name: str
    difficulty: str
    description: str
    normalize_bounds: tuple[float, float] | None
    tags: list[str] = field(default_factory=list)
    _load_get_action: Callable[[], Callable] | None = field(default=None, repr=False)

    def load_get_action(self):
        if self._load_get_action is None:
            raise RuntimeError(
                f"held-out partner {self.env_name}:{self.key} has no "
                "construction hook; env adapter must register it."
            )
        adapter = self._load_get_action()
        # Adapter returns a "partner wrapper" object with .get_action().
        # We want a plain (obs, state, rng) -> int callable.
        if hasattr(adapter, "get_action"):
            return adapter.get_action
        if callable(adapter):
            return adapter
        raise TypeError(f"partner {self.key} adapter has no get_action")


# Cache: parsed yaml + computed HeldoutPartner lists.
_CACHE: dict[tuple[str, str], list[HeldoutPartner]] = {}


def _parse_yaml() -> dict:
    """Parse the yaml once on first use. We don't import pyyaml at
    module load time in case it's absent on a minimal install."""
    try:
        import yaml  # noqa: WPS433
    except ImportError as exc:  # pragma: no cover
        raise ImportError(
            "pyyaml required to read global_heldout_settings.yaml; "
            "pip install pyyaml"
        ) from exc
    if not _HELDOUT_YAML.exists():
        log.warning("held-out yaml not found at %s", _HELDOUT_YAML)
        return {}
    return yaml.safe_load(_HELDOUT_YAML.read_text()) or {}


def _env_block(env_name: str) -> dict:
    data = _parse_yaml()
    heldout_set = data.get("heldout_set") or {}
    return heldout_set.get(env_name) or {}


def _normalize_bounds(entry: dict) -> tuple[float, float] | None:
    """Best-effort: pick the first (lo, hi) pair we can find under
    performance_bounds, preferring returned_episode_returns."""
    pb = entry.get("performance_bounds") or {}
    for key in ("returned_episode_returns", "base_return", "percent_eaten"):
        if key not in pb:
            continue
        val = pb[key]
        # Could be [lo, hi] or list-of-pairs for ego-multi-seed envs.
        if isinstance(val, list) and val:
            first = val[0]
            if isinstance(first, list) and len(first) == 2:
                return float(first[0]), float(first[1])
            if len(val) == 2 and not isinstance(first, list):
                return float(val[0]), float(val[1])
    return None


def load_heldout_partners(env_name: str, version: str = "v1") -> list[HeldoutPartner]:
    """Build the held-out catalog for (env, version). Cached on first
    call per (env, version)."""
    cache_key = (env_name, version)
    if cache_key in _CACHE:
        return _CACHE[cache_key]

    registry = env_registry()
    adapter = registry.get(env_name)
    if adapter is None:
        raise KeyError(f"env '{env_name}' has no registered adapter")

    # Build a lookup of adapter-provided PartnerSpecs by key.
    spec_by_key = {p.key: p for p in adapter.available_partners()}

    # Read yaml for bounds + any version-gate metadata.
    yaml_partners = _env_block(env_name)

    partners: list[HeldoutPartner] = []
    if yaml_partners:
        # For each yaml entry whose key matches an adapter partner,
        # create a HeldoutPartner. Entries without adapter support are
        # skipped with a log (e.g. "ippo_seed123" type RL-fixture entries
        # that the UI play-mode catalog doesn't expose).
        for yaml_key, yaml_entry in yaml_partners.items():
            if not isinstance(yaml_entry, dict):
                continue
            # Pick a matching adapter partner. For keys we've aligned
            # (iggi, piers, smartbot, etc.) the yaml key == adapter key.
            # For others we skip.
            spec = spec_by_key.get(yaml_key)
            if spec is None:
                log.debug("skipping %s:%s: no adapter partner", env_name, yaml_key)
                continue
            partners.append(HeldoutPartner(
                env_name=env_name,
                version=version,
                key=spec.key,
                display_name=spec.display_name,
                difficulty=spec.difficulty,
                description=spec.description,
                normalize_bounds=_normalize_bounds(yaml_entry),
                tags=list(spec.tags),
                _load_get_action=spec.load_fn,
            ))

    # If the yaml is empty for this env (e.g. mock, or a fresh env with
    # no yaml block), fall back to every partner the adapter exposes.
    if not partners:
        for spec in adapter.available_partners():
            partners.append(HeldoutPartner(
                env_name=env_name,
                version=version,
                key=spec.key,
                display_name=spec.display_name,
                difficulty=spec.difficulty,
                description=spec.description,
                normalize_bounds=None,
                tags=list(spec.tags),
                _load_get_action=spec.load_fn,
            ))

    _CACHE[cache_key] = partners
    return partners


def list_versions(env_name: str) -> list[str]:
    """Placeholder until the yaml has per-env version tags. Every env
    has one version ("v1") today."""
    try:
        get_env(env_name)
    except KeyError:
        return []
    return ["v1"]


def invalidate_cache() -> None:
    """Test-only hook so fixtures can reload the yaml."""
    _CACHE.clear()