-
Notifications
You must be signed in to change notification settings - Fork 309
feat: extend discoverability through CLI #2032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cf3e16b
eb4f974
acb801a
de0b55e
b73fd16
b6a7149
73fb362
afc9107
a300971
e59aaa6
68fe108
fd2d920
8c19560
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,18 +14,17 @@ | |
| # limitations under the License. | ||
| """Benchmark discovery and preparation utilities.""" | ||
|
|
||
| import re | ||
| import sys | ||
| from copy import deepcopy | ||
| from glob import glob | ||
| from pathlib import Path | ||
| from typing import Dict, List, Optional | ||
|
|
||
| from omegaconf import DictConfig, OmegaConf | ||
| from omegaconf.errors import InterpolationKeyError | ||
| from pydantic import BaseModel | ||
|
|
||
| from nemo_gym import PARENT_DIR | ||
| from nemo_gym.config_types import BenchmarkDatasetConfig | ||
| from nemo_gym.discovery import _parse_no_environment_tolerating_unset_values, discover_components | ||
| from nemo_gym.global_config import ( | ||
| POLICY_MODEL_KEY_NAME, | ||
| GlobalConfigDictParser, | ||
|
|
@@ -34,47 +33,12 @@ | |
| ) | ||
|
|
||
|
|
||
| BENCHMARKS_DIR = PARENT_DIR / "benchmarks" | ||
|
|
||
| # Fills unset `???`/`${...}` values during listing: they reference runtime-only values (API keys, | ||
| # endpoints) not needed to identify a benchmark, so a placeholder lets the config still resolve. | ||
| _UNSET_VALUE_PLACEHOLDER = "__unset_for_listing__" | ||
|
|
||
|
|
||
| def _parse_no_environment_tolerating_unset_values(initial_config_dict: DictConfig) -> DictConfig: | ||
| """`parse_no_environment` for *listing*: fill unset `???` values and undefined `${...}` interpolations | ||
| with a placeholder so a benchmark referencing runtime-only values can still be identified. Never mutates | ||
| the caller's config; errors other than those two propagate. | ||
|
|
||
| `???` is filled anywhere; `${...}` only where parse forces resolution (top-level and server sections), | ||
| not inside arbitrary non-server nested dicts — fine for listing, whose interpolations live in servers. | ||
| """ | ||
| working = deepcopy(initial_config_dict) # never mutate the caller's config | ||
| parser = GlobalConfigDictParser() | ||
|
|
||
| # Fill all `???` leaves in one pass. The loop below only adds placeholder keys, so no new `???` appear. | ||
| for path in parser.collect_missing_value_paths(working): | ||
| OmegaConf.update(working, path, _UNSET_VALUE_PLACEHOLDER) | ||
|
|
||
| # OmegaConf reports undefined `${...}` keys only one at a time (as InterpolationKeyError), so loop: | ||
| # inject a placeholder for each reported key and retry until it resolves. | ||
| injected: set[str] = set() | ||
| while True: | ||
| try: | ||
| return parser.parse_no_environment(initial_global_config_dict=working) | ||
| except InterpolationKeyError as e: | ||
| # The missing key name is only in the message text — omegaconf never stores it on an attribute | ||
| # (`e.key`/`e.full_key` point at the containing node), so a regex is the only way to read it. | ||
| match = re.search(r"Interpolation key '([^']+)'", str(e)) | ||
| key = match.group(1) if match else None | ||
| if not key or key in injected: | ||
| raise # can't identify/clear the missing key; let the caller decide (warn + skip) | ||
| injected.add(key) | ||
| working = OmegaConf.merge(DictConfig({key: _UNSET_VALUE_PLACEHOLDER}), working) | ||
| BENCHMARKS_SUBDIR = "benchmarks" | ||
| BENCHMARKS_DIR = PARENT_DIR / BENCHMARKS_SUBDIR | ||
|
|
||
|
|
||
| class BenchmarkConfig(BaseModel): | ||
| name: str | ||
| name: str # this is a dataset name, not the config name (they are usually the same) | ||
| path: Path | ||
| agent_name: str | ||
| num_repeats: int | ||
|
|
@@ -136,11 +100,35 @@ def from_initial_config_dict( | |
| ) | ||
|
|
||
|
|
||
| def _load_benchmarks_from_config_paths(config_paths: List[Path]) -> Dict[str, BenchmarkConfig]: | ||
| benchmarks_dict = dict() | ||
| for config_path in config_paths: | ||
| config_path = Path(config_path) | ||
| def _benchmark_config_name(rel_config_path: Path) -> str: | ||
| """The name of the benchmark config, given its path relative to ``benchmarks/``, sans ``.yaml``. | ||
|
|
||
| This is the identity we key benchmarks by, so a listed benchmark is always a valid ``--benchmark`` argument. | ||
| """ | ||
| rel = rel_config_path.with_suffix("") | ||
| parts = rel.parts | ||
| if len(parts) == 2 and parts[1] == "config": | ||
| return parts[0] | ||
| return rel.as_posix() | ||
|
|
||
|
|
||
| def _benchmark_config_paths(benchmarks_dir: Path) -> List[Path]: | ||
| """Sorted config paths under one dir that declare a benchmark, discovered by content. | ||
|
|
||
| A config is a benchmark iff it declares a `type: benchmark` dataset, regardless of filename, so we scan | ||
| every yaml. The `type: benchmark` text check is a cheap prefilter (pay the resolve cost only on real | ||
| candidates) that also catches non-`config.yaml` names like tau2's `configs/*.yaml`. Empty if dir missing. | ||
| """ | ||
| if not benchmarks_dir.is_dir(): | ||
| return [] | ||
| config_paths = [benchmarks_dir / p for p in glob("**/*.yaml", root_dir=benchmarks_dir, recursive=True)] | ||
| return sorted(p for p in config_paths if "type: benchmark" in p.read_text(errors="ignore")) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This only recognizes the exact text Could we parse the YAML here, or make the check accept normal YAML formatting differences?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll try with yaml parsing and if it doesn't work for any reason - extend the pattern here
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done in 97b5c9d I went with yaml and if the loading errors out we treat the config as potential benchmark. then all non-benchmarks fail to load and are filtered with a warning inside |
||
|
|
||
|
|
||
| def _discover_benchmarks_in_dir(benchmarks_dir: Path) -> Dict[str, BenchmarkConfig]: | ||
| """Map benchmark name -> :class:`BenchmarkConfig` for every benchmark config under one dir.""" | ||
| benchmarks_dict = dict() | ||
| for config_path in _benchmark_config_paths(benchmarks_dir): | ||
| try: | ||
| # Listing has no runtime context, so tolerate unset runtime-only values. | ||
| maybe_bc = BenchmarkConfig.from_config_path(config_path, strict=False) | ||
|
|
@@ -156,11 +144,20 @@ def _load_benchmarks_from_config_paths(config_paths: List[Path]) -> Dict[str, Be | |
| if not maybe_bc: | ||
| continue | ||
|
|
||
| benchmarks_dict[maybe_bc.name] = maybe_bc | ||
| benchmarks_dict[_benchmark_config_name(config_path.relative_to(benchmarks_dir))] = maybe_bc | ||
|
|
||
| return benchmarks_dict | ||
|
|
||
|
|
||
| def discover_benchmarks() -> Dict[str, BenchmarkConfig]: | ||
| """Map benchmark name -> :class:`BenchmarkConfig` for every discoverable benchmark config. | ||
|
|
||
| Scans the ``benchmarks/`` subdir of every :func:`~nemo_gym.discovery.component_search_roots` root | ||
| (``NEMO_GYM_EXTRA_ROOTS`` + cwd + built-ins), merged so user benchmarks shadow same-named built-ins. | ||
| """ | ||
| return discover_components(BENCHMARKS_SUBDIR, _discover_benchmarks_in_dir) | ||
|
|
||
|
|
||
| # Backward-compatibility shims (CLI refactor): these symbols moved to `nemo_gym.cli.eval`. | ||
| # Re-exported lazily to avoid a circular import; accessing them emits a DeprecationWarning. | ||
| from nemo_gym.cli._compat import moved_attr_getter # noqa: E402 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could file lookup and Python imports share the same root-ordering logic?
component_search_roots()gives extra roots priority overPARENT_DIR, but_augment_sys_path()can leavePARENT_DIRbefore the extra roots because it was already added tosys.path. This means a plugin can win during file resolution but lose during Python import when Gym contains a module with the same import name.Is there a way for both resolvers to use the same ordered roots, so their precedence cannot diverge over time?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch.
I wonder if I should just ensure that
PARENT_DIRis at the end, or in general that the extra roots are added before any other paths. I think the order should be: 1) extra roots, 2) originalsys.pathexcludingPARENT_DIR, 3)PARENT_DIR. What do you think?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done in ee0c74c, let me know if the ordering should be different